From 6bca25c2ef1ef5192c861d4bc32f1bc72a777d0b Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Sun, 1 Sep 2024 13:28:27 +0100 Subject: [PATCH 01/24] Add db connections and migrations. Add a script for populating user album/playlist data. --- ansible/.ansible-secrets.template.yml | 1 + backend/.env.j2 | 3 + backend/.env.template | 3 + backend/.env.test | 3 + backend/poetry.lock | 81 +++++++++++++++++++- backend/pyproject.toml | 4 + backend/src/app.py | 3 + backend/src/controllers/database.py | 55 ++++++++++++++ backend/src/controllers/spotify.py | 2 + backend/src/database/crud/album.py | 37 +++++++++ backend/src/database/crud/playlist.py | 46 ++++++++++++ backend/src/database/crud/user.py | 28 +++++++ backend/src/database/migrations/init.py | 43 +++++++++++ backend/src/database/models.py | 95 ++++++++++++++++++++++++ backend/src/dataclasses/playlist_info.py | 1 + backend/src/flask_config.py | 1 + backend/src/spotify.py | 2 +- frontend/src/api/index.ts | 4 + 18 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 backend/src/controllers/database.py create mode 100644 backend/src/database/crud/album.py create mode 100644 backend/src/database/crud/playlist.py create mode 100644 backend/src/database/crud/user.py create mode 100644 backend/src/database/migrations/init.py create mode 100644 backend/src/database/models.py 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 From 3bbe30b3c6d7383cad3ddadc46a5abafa9527c8e Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Wed, 4 Sep 2024 07:59:35 +0100 Subject: [PATCH 02/24] Add album details info generation scripts --- backend/src/app.py | 7 +- backend/src/controllers/database.py | 96 ++++++++++++++++++- backend/src/database/crud/album.py | 75 +++++++++++++-- backend/src/database/crud/artist.py | 20 ++++ backend/src/database/crud/genre.py | 5 + backend/src/database/crud/playlist.py | 22 ++++- backend/src/database/models.py | 4 +- backend/src/dataclasses/linked_from.py | 13 +-- .../musicbrainz/release_group_response.py | 16 ++++ backend/src/dataclasses/simplified_track.py | 2 +- backend/src/musicbrainz.py | 61 ++++++++++++ backend/src/spotify.py | 14 +++ frontend/src/AppRoutes.tsx | 2 + frontend/src/api/index.ts | 12 ++- frontend/src/api/jsonRequest.ts | 41 ++++++++ frontend/src/components/Button.tsx | 2 +- frontend/src/components/DropdownMenu.tsx | 2 +- frontend/src/components/LinkButton.tsx | 17 ++++ frontend/src/components/LoadingSpinner.tsx | 14 +++ frontend/src/presentational/Header/Header.tsx | 2 +- .../src/presentational/Header/UserMenu.tsx | 4 +- .../PopulateUserDatabaseButton.tsx | 29 ++++++ frontend/src/settingsPage/SettingsPage.tsx | 16 ++++ 23 files changed, 445 insertions(+), 31 deletions(-) create mode 100644 backend/src/database/crud/artist.py create mode 100644 backend/src/database/crud/genre.py create mode 100644 backend/src/dataclasses/musicbrainz/release_group_response.py create mode 100644 backend/src/musicbrainz.py create mode 100644 frontend/src/components/LinkButton.tsx create mode 100644 frontend/src/components/LoadingSpinner.tsx create mode 100644 frontend/src/settingsPage/PopulateUserDatabaseButton.tsx create mode 100644 frontend/src/settingsPage/SettingsPage.tsx diff --git a/backend/src/app.py b/backend/src/app.py index 657c767..6c0e1bc 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -4,6 +4,7 @@ from src.controllers.spotify import spotify_controller from src.exceptions.Unauthorized import UnauthorizedException from src.flask_config import Config +from src.musicbrainz import MusicbrainzClient from src.spotify import SpotifyClient from src.controllers.auth import auth_controller @@ -11,6 +12,8 @@ def create_app(): app = Flask(__name__) spotify = SpotifyClient() + musicbrainz = MusicbrainzClient() + app.config.from_object(Config()) app.config["CORS_HEADERS"] = "Content-Type" @@ -45,6 +48,8 @@ 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)) + app.register_blueprint( + database_controller(spotify=spotify, musicbrainz=musicbrainz) + ) return app diff --git a/backend/src/controllers/database.py b/backend/src/controllers/database.py index 1c3f9f3..58b0c42 100644 --- a/backend/src/controllers/database.py +++ b/backend/src/controllers/database.py @@ -1,16 +1,27 @@ -from logging import Logger from flask import Blueprint, make_response, request +from src.database.crud.album import ( + add_genres_to_album, + get_album_artists, + get_album_genres, + get_user_albums, + update_album, +) +from src.database.crud.genre import create_genre from src.database.crud.playlist import ( create_playlist, + get_playlist_albums, get_playlist_by_id_or_none, + get_user_playlists, update_playlist, ) from src.database.crud.user import get_or_create_user +from src.musicbrainz import MusicbrainzClient from src.spotify import SpotifyClient +from time import sleep -def database_controller(spotify: SpotifyClient): +def database_controller(spotify: SpotifyClient, musicbrainz: MusicbrainzClient): database_controller = Blueprint( name="database_controller", import_name=__name__, url_prefix="/database" ) @@ -22,7 +33,7 @@ def populate_user(): simplified_playlists = spotify.get_all_playlists( user_id=user.id, access_token=access_token ) - get_or_create_user(user) + (db_user,) = get_or_create_user(user) for simplified_playlist in simplified_playlists: if "Albums" in simplified_playlist.name: @@ -37,7 +48,7 @@ def populate_user(): access_token=access_token, id=simplified_playlist.id ), ] - create_playlist(playlist, albums) + create_playlist(playlist, albums, db_user) else: if db_playlist.snapshot_id != simplified_playlist.snapshot_id: [playlist, albums] = [ @@ -52,4 +63,81 @@ def populate_user(): return make_response("Playlist data populated", 201) + @database_controller.route( + "populate_additional_album_details_from_playlist", methods=["GET"] + ) + def populate_additional_album_details_from_playlist(): + access_token = request.cookies.get("spotify_access_token") + user = spotify.get_current_user(access_token) + playlists = get_user_playlists(user.id) + + for playlist in playlists: + albums = get_playlist_albums(playlist.id) + if albums == []: + continue + batch_albums = split_list(albums, 20) + for album_chunk in batch_albums: + sleep(0.5) + albums = spotify.get_multiple_albums( + access_token=access_token, ids=[album.id for album in album_chunk] + ) + for db_album in albums: + album = spotify.get_album(access_token=access_token, id=db_album.id) + update_album(album) + + return make_response("Playlist data populated", 201) + + @database_controller.route("populate_additional_album_details", methods=["GET"]) + def populate_additional_album_details(): + access_token = request.cookies.get("spotify_access_token") + user = spotify.get_current_user(access_token) + albums = get_user_albums(user.id) + albums_without_label = [album for album in albums] # if album.label is None] + if albums_without_label == []: + return make_response("No Albums to process", 204) + batch_albums = split_list(albums_without_label, 20) + for album_chunk in batch_albums: + sleep(0.5) + albums = spotify.get_multiple_albums( + access_token=access_token, ids=[album.id for album in album_chunk] + ) + for db_album in albums: + db_album.genres = musicbrainz.get_album_genres( + db_album.artists[0].name, db_album.name + ) + update_album(db_album) + + return make_response("Playlist data populated", 201) + + @database_controller.route("populate_universal_genre_list", methods=["GET"]) + def populate_universal_genre_list(): + genre_list = musicbrainz.get_genre_list() + [create_genre(genre) for genre in genre_list] + return make_response("Playlist data populated", 201) + return database_controller + + +def split_list(input_list, max_length=20): + return [ + input_list[i : i + max_length] for i in range(0, len(input_list), max_length) + ] + + +def populate_user_album_genres(user_id: str): + albums = get_user_albums(user_id=user_id) + print(f"processing album {0} of {len(albums)}") + skip_count = 0 + for idx, db_album in enumerate(albums): + print("\033[A \033[A") + print(f"processing album {idx} of {len(albums)}, skipped {skip_count}") + if get_album_genres(db_album) != []: + skip_count += 1 + continue + album_artists = get_album_artists(db_album) + genres = MusicbrainzClient().get_album_genres( + artist_name=album_artists[0].name, album_title=db_album.name + ) + add_genres_to_album(db_album, genres) + print("\033[A \033[A") + print(f"completed. Processed {len(albums)} albums. Skipped ") diff --git a/backend/src/database/crud/album.py b/backend/src/database/crud/album.py index ca7a519..3deb501 100644 --- a/backend/src/database/crud/album.py +++ b/backend/src/database/crud/album.py @@ -1,10 +1,14 @@ +from typing import List +from src.database.crud.artist import create_or_update_artist from src.dataclasses.album import Album from src.database.models import ( AlbumArtistRelationship, AlbumGenreRelationship, + DbArtist, DbGenre, DbAlbum, - DbArtist, + DbPlaylist, + PlaylistAlbumRelationship, ) @@ -23,15 +27,72 @@ def create_album_or_none(album: Album): 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, - ) + create_or_update_artist(artist) 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 + + +def update_album(album: Album): + DbAlbum.update( + 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, + ).where(DbAlbum.id == album.id).execute() + + for artist in album.artists: + create_or_update_artist(artist) + AlbumArtistRelationship.get_or_create(album=album.id, artist=artist.id) + for genre in album.genres or []: + db_genre = DbGenre.get_or_none(name=genre) + if db_genre: + AlbumGenreRelationship.get_or_create(album=album.id, genre=db_genre.id) + + return album + + +def get_album_genres(album: DbAlbum) -> List[str]: + query = ( + DbGenre.select() + .join(AlbumGenreRelationship) + .join(DbAlbum) + .where(DbAlbum.id == album.id) + ) + return list(query) + + +def add_genres_to_album(album: DbAlbum, genres: List[str]) -> DbAlbum: + for genre in genres or []: + db_genre = DbGenre.get_or_none(name=genre) + if db_genre: + AlbumGenreRelationship.get_or_create(album=album.id, genre=db_genre.id) + + return album + + +def get_album_artists(album: DbAlbum) -> List[DbArtist]: + query = ( + DbArtist.select() + .join(AlbumArtistRelationship) + .join(DbAlbum) + .where(DbAlbum.id == album.id) + ) + return list(query) + + +def get_user_albums(user_id: str) -> List[DbAlbum]: + query = ( + DbAlbum.select() + .join(PlaylistAlbumRelationship) + .join(DbPlaylist) + .where(DbPlaylist.user == user_id) + ) + return list(query) diff --git a/backend/src/database/crud/artist.py b/backend/src/database/crud/artist.py new file mode 100644 index 0000000..7717667 --- /dev/null +++ b/backend/src/database/crud/artist.py @@ -0,0 +1,20 @@ +from src.database.models import DbArtist +from src.dataclasses.artist import Artist + + +def create_or_update_artist(artist: Artist): + db_artist, created = DbArtist.get_or_create( + id=artist.id, + defaults={ + "image_url": artist.images[0].url if artist.images else None, + "name": artist.name, + "uri": artist.uri, + }, + ) + if not created: + if artist.images: + db_artist.image_url = artist.images[0].url + db_artist.name = artist.name + db_artist.uri = artist.uri + db_artist.save() + return db_artist diff --git a/backend/src/database/crud/genre.py b/backend/src/database/crud/genre.py new file mode 100644 index 0000000..2f3b087 --- /dev/null +++ b/backend/src/database/crud/genre.py @@ -0,0 +1,5 @@ +from src.database.models import DbGenre + + +def create_genre(name: str): + return DbGenre.get_or_create(name=name) diff --git a/backend/src/database/crud/playlist.py b/backend/src/database/crud/playlist.py index f9fb67a..9d57b48 100644 --- a/backend/src/database/crud/playlist.py +++ b/backend/src/database/crud/playlist.py @@ -1,6 +1,6 @@ from typing import List from src.database.crud.album import create_album_or_none -from src.database.models import DbPlaylist, PlaylistAlbumRelationship +from src.database.models import DbAlbum, DbPlaylist, DbUser, PlaylistAlbumRelationship from src.dataclasses.album import Album from src.dataclasses.playlist import Playlist @@ -9,13 +9,13 @@ 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]): +def create_playlist(playlist: Playlist, albums: List[Album], user: DbUser): 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, + user=user.id, snapshot_id=playlist.snapshot_id, uri=playlist.uri, ) @@ -33,7 +33,7 @@ def update_playlist(playlist: Playlist, albums: List[Album]): description=playlist.description, image_url=playlist.images[0].url if playlist.images else None, name=playlist.name, - owner=playlist.owner.id, + user_id=playlist.owner.id, snapshot_id=playlist.snapshot_id, uri=playlist.uri, ) @@ -44,3 +44,17 @@ def update_playlist(playlist: Playlist, albums: List[Album]): PlaylistAlbumRelationship.create(playlist=playlist.id, album=album.id) return playlist + + +def get_user_playlists(user_id: str) -> List[DbPlaylist]: + return DbPlaylist.select().where(DbPlaylist.user == user_id).execute() + + +def get_playlist_albums(playlist_id: str) -> List[DbAlbum]: + query = ( + DbAlbum.select() + .join(PlaylistAlbumRelationship) + .join(DbPlaylist) + .where(DbPlaylist.id == playlist_id) + ) + return list(query) diff --git a/backend/src/database/models.py b/backend/src/database/models.py index 61f0041..a252f3f 100644 --- a/backend/src/database/models.py +++ b/backend/src/database/models.py @@ -31,7 +31,7 @@ class DbPlaylist(BaseModel): description = CharField() image_url = CharField(null=True) name = CharField() - owner = DbUser() + user = ForeignKeyField(DbUser, backref="owner", to_field="id") snapshot_id = CharField() uri = CharField() @@ -89,7 +89,7 @@ class Meta: class AlbumGenreRelationship(BaseModel): album = ForeignKeyField(DbAlbum, backref="genres") - genre = ForeignKeyField(DbArtist, backref="albums") + genre = ForeignKeyField(DbGenre, backref="albums") class Meta: indexes = ((("album", "genre"), True),) diff --git a/backend/src/dataclasses/linked_from.py b/backend/src/dataclasses/linked_from.py index 0b429d1..a8a02a2 100644 --- a/backend/src/dataclasses/linked_from.py +++ b/backend/src/dataclasses/linked_from.py @@ -1,10 +1,11 @@ +from typing import Optional from pydantic import BaseModel from src.dataclasses.external_urls import ExternalUrls -class LinkedFrom(BaseModel): - external_urls: ExternalUrls - href: str - id: str - type: str - uri: str +class LinkedFrom(BaseModel): + external_urls: Optional[ExternalUrls] = None + href: Optional[str] = None + id: Optional[str] = None + type: Optional[str] = None + uri: Optional[str] = None diff --git a/backend/src/dataclasses/musicbrainz/release_group_response.py b/backend/src/dataclasses/musicbrainz/release_group_response.py new file mode 100644 index 0000000..96aeb6b --- /dev/null +++ b/backend/src/dataclasses/musicbrainz/release_group_response.py @@ -0,0 +1,16 @@ +from typing import List, Optional +from pydantic import BaseModel, Field + + +class Tag(BaseModel): + count: int + name: str + + +class ReleaseGroup(BaseModel): + tags: Optional[List[Tag]] = None + + +class ReleaseGroupResponse(BaseModel): + count: int + release_groups: List[ReleaseGroup] = Field(alias="release-groups") diff --git a/backend/src/dataclasses/simplified_track.py b/backend/src/dataclasses/simplified_track.py index 531ecef..50e2984 100644 --- a/backend/src/dataclasses/simplified_track.py +++ b/backend/src/dataclasses/simplified_track.py @@ -19,7 +19,7 @@ class SimplifiedTrack(BaseModel): linked_from: Optional[LinkedFrom] = None restrictions: Optional[Restrictions] = None name: str - preview_url: str + preview_url: Optional[str] = None track_number: int type: str uri: str diff --git a/backend/src/musicbrainz.py b/backend/src/musicbrainz.py new file mode 100644 index 0000000..e3a6d25 --- /dev/null +++ b/backend/src/musicbrainz.py @@ -0,0 +1,61 @@ +import requests +from typing import List +from urllib.parse import quote_plus +from src.dataclasses.musicbrainz.release_group_response import ReleaseGroupResponse +from src.exceptions.Unauthorized import UnauthorizedException +from time import sleep + + +class MusicbrainzClient: + request_headers = { + "Accept": "application/json", + "User-Agent": "Application PlaylistManager/1.0 - Hobby project (calpinsw@gmail.com)", + } + + def response_handler(self, response: requests.Response, jsonify=True): + if response.status_code == 401: + raise UnauthorizedException + else: + if jsonify: + if response.status_code == 204: + return None + else: + return response.json() + else: + return response + + def get_genre_list(self, limit=100, offset=0) -> List[str]: + if offset == 0: + query = f"?limit={limit}" + else: + query = f"?limit={limit}&offset={offset}" + response = requests.get( + url="https://musicbrainz.org/ws/2/genre/all" + query, + headers=self.request_headers, + ) + data = response.json() + genre_list = [genre["name"] for genre in data["genres"]] + if genre_list == []: + return [] + else: + sleep(2) # Needed to avoid rate limiting + return genre_list + self.get_genre_list(limit, offset + limit) + + def get_album_genres(self, artist_name: str, album_title: str) -> List[str]: + query = quote_plus( + f'artistname:"{artist_name}" AND releasegroup:"{album_title}"' + ) + response = requests.get( + url="https://musicbrainz.org/ws/2/release-group?query=" + query, + headers=self.request_headers, + ) + data = response.json() + release_group_response = ReleaseGroupResponse.model_validate(data) + sleep(1) + if release_group_response.count == 0: + return [] + return [ + tag.name + for tag in release_group_response.release_groups[0].tags or [] + if tag.count > 0 + ] diff --git a/backend/src/spotify.py b/backend/src/spotify.py index 0411ca9..9f00a1c 100644 --- a/backend/src/spotify.py +++ b/backend/src/spotify.py @@ -1,6 +1,7 @@ import json import requests import os +import urllib.parse from typing import List, Optional from flask import Response, make_response, redirect from src.dataclasses.album import Album @@ -292,6 +293,19 @@ def get_album(self, access_token, id): album = Album.model_validate(api_album) return album + def get_multiple_albums(self, access_token, ids: List[str]) -> List[Album]: + encoded_ids = urllib.parse.quote_plus(",".join(ids)) + response = requests.get( + f"https://api.spotify.com/v1/albums?ids={encoded_ids}", + headers={ + "content-type": "application/json", + }, + auth=BearerAuth(access_token), + ) + api_albums = self.response_handler(response) + albums = [Album.model_validate(api_album) for api_album in api_albums["albums"]] + return albums + def get_current_playback(self, access_token) -> PlaybackState | None: response = requests.get( f"https://api.spotify.com/v1/me/player", diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx index 71b8d3c..18c7cdc 100644 --- a/frontend/src/AppRoutes.tsx +++ b/frontend/src/AppRoutes.tsx @@ -5,6 +5,7 @@ import Layout from "./presentational/Layout"; import { getPlaylist } from "./api"; import { Login } from "./Login"; import { Index } from "./MainPage"; +import { SettingsPage } from "./settingsPage/SettingsPage"; const router = createBrowserRouter([ { @@ -13,6 +14,7 @@ const router = createBrowserRouter([ children: [ { index: true, element: }, { path: "/login", element: }, + { path: "/settings", element: }, { path: "/edit/:playlistId", element: , diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 38294c8..afc1199 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -2,7 +2,7 @@ import { Album } from "../interfaces/Album"; import { PlaybackInfo, PlaylistProgress } from "../interfaces/PlaybackInfo"; import { Playlist } from "../interfaces/Playlist"; import { User } from "../interfaces/User"; -import { backendUrl } from "./jsonRequest"; +import { backendUrl, request } from "./jsonRequest"; import { RequestMethod } from "./jsonRequest"; import { jsonRequest } from "./jsonRequest"; @@ -136,5 +136,13 @@ export const pauseOrStartPlayback = async (): Promise => { }; export const populateUserData = async (): Promise => { - return jsonRequest(`database/populate_user`, RequestMethod.GET); + return request(`database/populate_user`, RequestMethod.GET); +} + +export const populateAdditionalAlbumDetails = async (): Promise => { + return request('database/populate_additional_album_details', RequestMethod.GET) +} + +export const populateUniversalGenreList = async (): Promise => { + return request('database/populate_universal_genre_list', RequestMethod.GET) } \ No newline at end of file diff --git a/frontend/src/api/jsonRequest.ts b/frontend/src/api/jsonRequest.ts index 4869fbb..0199257 100644 --- a/frontend/src/api/jsonRequest.ts +++ b/frontend/src/api/jsonRequest.ts @@ -48,3 +48,44 @@ export const jsonRequest = async ( const apiResponse = response.json().then((data) => data as O); return apiResponse; }; + +export const request = async ( + endpoint: string, + method: RequestMethod = RequestMethod.GET, + data?: I, + redirectOnUnauthorized = true, +) => { + let fetchOptions: RequestInit = { credentials: "include" }; + switch (method) { + case RequestMethod.POST: + case RequestMethod.PUT: + fetchOptions = { + ...fetchOptions, + method: method, + credentials: "include", + mode: "cors", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }; + } + const response = await fetch(`${backendUrl}/${endpoint}`, fetchOptions); + if (response.status === 401 && redirectOnUnauthorized) { + const refresh_response = await fetch( + `${backendUrl}/auth/refresh-user-code`, + { credentials: "include" }, + ); + if (refresh_response.status != 401) { + const retried_response = await fetch( + `${backendUrl}/${endpoint}`, + fetchOptions, + ); + return retried_response; + } else { + openInNewTab(`/login`); + } + } + const apiResponse = response; + return apiResponse; +}; diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index cc9960e..e3f61e9 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -10,7 +10,7 @@ const Button: FC< + /> ); }; diff --git a/frontend/src/components/DropdownMenu.tsx b/frontend/src/components/DropdownMenu.tsx index e67d03f..510a0f2 100644 --- a/frontend/src/components/DropdownMenu.tsx +++ b/frontend/src/components/DropdownMenu.tsx @@ -28,7 +28,7 @@ const DropdownMenu: FC = ({ children, trigger, isMenuOpen, closeMenu {isMenuOpen &&
{children} diff --git a/frontend/src/components/LinkButton.tsx b/frontend/src/components/LinkButton.tsx new file mode 100644 index 0000000..3af2405 --- /dev/null +++ b/frontend/src/components/LinkButton.tsx @@ -0,0 +1,17 @@ +import React, { FC } from "react"; + +const Button: FC< + React.DetailedHTMLProps< + React.AnchorHTMLAttributes, + HTMLAnchorElement + > +> = ({className, ...props}) => { + return ( + + ); +}; + +export default Button; diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..822fe9f --- /dev/null +++ b/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,14 @@ +import React from "react" + +const LoadingSpinner = ({}) => { + return ( +
+ +
+ ) +} + +export default LoadingSpinner \ No newline at end of file diff --git a/frontend/src/presentational/Header/Header.tsx b/frontend/src/presentational/Header/Header.tsx index ae93cda..11396d6 100644 --- a/frontend/src/presentational/Header/Header.tsx +++ b/frontend/src/presentational/Header/Header.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { Link } from "react-router-dom"; -import { getCurrentUserDetails, login } from "../../api"; +import { getCurrentUserDetails, login, populateUserData} from "../../api"; import { useQuery } from "@tanstack/react-query"; import { User } from "../../interfaces/User"; import UserMenu from "./UserMenu"; diff --git a/frontend/src/presentational/Header/UserMenu.tsx b/frontend/src/presentational/Header/UserMenu.tsx index ad36d40..3abe0f5 100644 --- a/frontend/src/presentational/Header/UserMenu.tsx +++ b/frontend/src/presentational/Header/UserMenu.tsx @@ -3,6 +3,7 @@ import DropdownMenu from "../../components/DropdownMenu"; import { User } from "../../interfaces/User"; import Button from "../../components/Button"; import { logout } from "../../api"; +import LinkButton from "../../components/LinkButton"; interface UserMenuProps { userData: User; @@ -23,7 +24,8 @@ const UserMenu: FC = ({userData}) => { } > - + Settings + ) } diff --git a/frontend/src/settingsPage/PopulateUserDatabaseButton.tsx b/frontend/src/settingsPage/PopulateUserDatabaseButton.tsx new file mode 100644 index 0000000..ae36d18 --- /dev/null +++ b/frontend/src/settingsPage/PopulateUserDatabaseButton.tsx @@ -0,0 +1,29 @@ +import React, { FC, useState } from "react"; +import Button from "../components/Button"; +import LoadingSpinner from "../components/LoadingSpinner"; + +interface ButtonWithLoadingStateProps { + actionCallback: () => Promise; + text: string; +} + +export const ButtonWithLoadingState: FC = ({actionCallback, text}) => { + const [isLoading, setIsLoading] = useState(false) + const onClick = async (): Promise => { + setIsLoading(true) + try { + await actionCallback() + } + finally { + setIsLoading(false) + } + } + + return ( +
+
+ +
+
+ ); +}; diff --git a/frontend/src/settingsPage/SettingsPage.tsx b/frontend/src/settingsPage/SettingsPage.tsx new file mode 100644 index 0000000..50dc58c --- /dev/null +++ b/frontend/src/settingsPage/SettingsPage.tsx @@ -0,0 +1,16 @@ +import React, { FC } from "react"; +import { ButtonWithLoadingState } from "./PopulateUserDatabaseButton"; +import { populateAdditionalAlbumDetails, populateUniversalGenreList, populateUserData } from "../api"; + +export const SettingsPage: FC = () => { + return ( +
+

Settings

+
+ + + +
+
+ ); +}; From 912fd96197eb3baa52780b09ad24ffc07b68e707 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Wed, 4 Sep 2024 08:03:11 +0100 Subject: [PATCH 03/24] Add snapshot_id to tests --- backend/src/tests/mock_builders/simplified_playlist_builder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/tests/mock_builders/simplified_playlist_builder.py b/backend/src/tests/mock_builders/simplified_playlist_builder.py index 5e607dd..47b1fd6 100644 --- a/backend/src/tests/mock_builders/simplified_playlist_builder.py +++ b/backend/src/tests/mock_builders/simplified_playlist_builder.py @@ -11,6 +11,7 @@ def simplified_playlist_builder( description="", images=[image_builder()], tracks=playlist_info_tracks_builder(), + snapshot_id="snapshot1", ): return SimplifiedPlaylist.model_validate( { @@ -19,5 +20,6 @@ def simplified_playlist_builder( "description": description, "images": images, "tracks": tracks, + "snapshot_id": snapshot_id, } ) From e817a78b0f9213efcdcb633f85a7f0b4f2ab7705 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Wed, 4 Sep 2024 08:17:14 +0100 Subject: [PATCH 04/24] Move musicbrainz config to env --- ansible/.ansible-secrets.template.yml | 4 +++- backend/.env.j2 | 6 +++++- backend/.env.template | 4 ++++ backend/.env.test | 4 ++++ backend/src/flask_config.py | 2 ++ backend/src/musicbrainz.py | 11 +++++++---- 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/ansible/.ansible-secrets.template.yml b/ansible/.ansible-secrets.template.yml index fdfe064..79f41ba 100644 --- a/ansible/.ansible-secrets.template.yml +++ b/ansible/.ansible-secrets.template.yml @@ -2,4 +2,6 @@ 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 +musicbrainz_url: musicbrainz_url +musicbrainz_user_agent: musicbrainz_user_agent +db_connection_string: db_connection_string diff --git a/backend/.env.j2 b/backend/.env.j2 index 1147a03..e463a98 100644 --- a/backend/.env.j2 +++ b/backend/.env.j2 @@ -15,5 +15,9 @@ SPOTIFY_CLIENT_ID={{spotify_client_id}} SPOTIFY_SECRET={{spotify_secret}} SPOTIFY_REDIRECT_URI="{{backend_url}}/auth/get-user-code" +# Musicbrainz Api +MUSICBRAINZ_URL={{musicbrainz_url}} +MUSICBRAINZ_USER_AGENT={{musicbrainz_user_agent}} + # Database Connection String -DB_CONNECTION_STRING=postgresql://username:password@hostname:port/database +DB_CONNECTION_STRING={{db_connection_string}} \ No newline at end of file diff --git a/backend/.env.template b/backend/.env.template index 683f90a..b5ad68d 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -15,5 +15,9 @@ SPOTIFY_CLIENT_ID="https://developer.spotify.com/dashboard" SPOTIFY_SECRET="https://developer.spotify.com/dashboard" SPOTIFY_REDIRECT_URI="http://localhost:8080/spotify-redirect" +# Musicbrainz Api +MUSICBRAINZ_URL=https://musicbrainz.org/ws/2 +MUSICBRAINZ_USER_AGENT="Application PlaylistManager/1.0 - Hobby project (put maintainers email here)" + # Database Connection String DB_CONNECTION_STRING=postgresql://username:password@hostname:port/database diff --git a/backend/.env.test b/backend/.env.test index 683f90a..b5ad68d 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -15,5 +15,9 @@ SPOTIFY_CLIENT_ID="https://developer.spotify.com/dashboard" SPOTIFY_SECRET="https://developer.spotify.com/dashboard" SPOTIFY_REDIRECT_URI="http://localhost:8080/spotify-redirect" +# Musicbrainz Api +MUSICBRAINZ_URL=https://musicbrainz.org/ws/2 +MUSICBRAINZ_USER_AGENT="Application PlaylistManager/1.0 - Hobby project (put maintainers email here)" + # Database Connection String DB_CONNECTION_STRING=postgresql://username:password@hostname:port/database diff --git a/backend/src/flask_config.py b/backend/src/flask_config.py index 233989e..a2cde33 100644 --- a/backend/src/flask_config.py +++ b/backend/src/flask_config.py @@ -8,6 +8,8 @@ def __init__(self): 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") + self.MUSICBRAINZ_URL = os.environ.get("MUSICBRAINZ_URL") + self.MUSICBRAINZ_USER_AGENT = os.environ.get("MUSICBRAINZ_USER_AGENT") 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/musicbrainz.py b/backend/src/musicbrainz.py index e3a6d25..8a9ef51 100644 --- a/backend/src/musicbrainz.py +++ b/backend/src/musicbrainz.py @@ -5,11 +5,14 @@ from src.exceptions.Unauthorized import UnauthorizedException from time import sleep +from src.flask_config import Config + +### Musicbrainz requests must be followed by a 1 second sleep if being sent in bulk to avoid rate limiting class MusicbrainzClient: request_headers = { "Accept": "application/json", - "User-Agent": "Application PlaylistManager/1.0 - Hobby project (calpinsw@gmail.com)", + "User-Agent": Config().MUSICBRAINZ_USER_AGENT, } def response_handler(self, response: requests.Response, jsonify=True): @@ -30,7 +33,7 @@ def get_genre_list(self, limit=100, offset=0) -> List[str]: else: query = f"?limit={limit}&offset={offset}" response = requests.get( - url="https://musicbrainz.org/ws/2/genre/all" + query, + url=Config().MUSICBRAINZ_URL + "/genre/all" + query, headers=self.request_headers, ) data = response.json() @@ -38,7 +41,7 @@ def get_genre_list(self, limit=100, offset=0) -> List[str]: if genre_list == []: return [] else: - sleep(2) # Needed to avoid rate limiting + sleep(1) return genre_list + self.get_genre_list(limit, offset + limit) def get_album_genres(self, artist_name: str, album_title: str) -> List[str]: @@ -46,7 +49,7 @@ def get_album_genres(self, artist_name: str, album_title: str) -> List[str]: f'artistname:"{artist_name}" AND releasegroup:"{album_title}"' ) response = requests.get( - url="https://musicbrainz.org/ws/2/release-group?query=" + query, + url=Config().MUSICBRAINZ_URL + "/release-group?query=" + query, headers=self.request_headers, ) data = response.json() From ae05f1fad3cf9ad2eebd71e5dee820cef5320f9a Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Wed, 4 Sep 2024 08:24:40 +0100 Subject: [PATCH 05/24] Add route and button for populating userAlbumGenres --- backend/src/app.py | 2 +- backend/src/controllers/database.py | 17 +++++++++++++---- frontend/src/api/index.ts | 4 ++++ frontend/src/settingsPage/SettingsPage.tsx | 3 ++- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/backend/src/app.py b/backend/src/app.py index 6c0e1bc..5dd8f6a 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -25,7 +25,7 @@ def create_app(): SESSION_COOKIE_SECURE="True", ) - cors = CORS( + CORS( app, resources={ r"/*": { diff --git a/backend/src/controllers/database.py b/backend/src/controllers/database.py index 58b0c42..0123153 100644 --- a/backend/src/controllers/database.py +++ b/backend/src/controllers/database.py @@ -107,13 +107,20 @@ def populate_additional_album_details(): ) update_album(db_album) - return make_response("Playlist data populated", 201) + return make_response("Playlist details populated", 201) @database_controller.route("populate_universal_genre_list", methods=["GET"]) def populate_universal_genre_list(): genre_list = musicbrainz.get_genre_list() [create_genre(genre) for genre in genre_list] - return make_response("Playlist data populated", 201) + return make_response("Genre data populated", 201) + + @database_controller.route("populate_user_album_genres", methods=["GET"]) + def populate_user_album_genres(): + access_token = request.cookies.get("spotify_access_token") + user = spotify.get_current_user(access_token) + populate_album_genres_by_user_id(user.id, musicbrainz) + return make_response("User album genres populated", 201) return database_controller @@ -124,7 +131,9 @@ def split_list(input_list, max_length=20): ] -def populate_user_album_genres(user_id: str): +def populate_album_genres_by_user_id( + user_id: str, musicbrainz: MusicbrainzClient = MusicbrainzClient() +): albums = get_user_albums(user_id=user_id) print(f"processing album {0} of {len(albums)}") skip_count = 0 @@ -135,7 +144,7 @@ def populate_user_album_genres(user_id: str): skip_count += 1 continue album_artists = get_album_artists(db_album) - genres = MusicbrainzClient().get_album_genres( + genres = musicbrainz.get_album_genres( artist_name=album_artists[0].name, album_title=db_album.name ) add_genres_to_album(db_album, genres) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index afc1199..09f9aec 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -145,4 +145,8 @@ export const populateAdditionalAlbumDetails = async (): Promise => { export const populateUniversalGenreList = async (): Promise => { return request('database/populate_universal_genre_list', RequestMethod.GET) +} + +export const populateUserAlbumGenres = async (): Promise => { + return request('database/populate_user_album_genres', RequestMethod.GET) } \ No newline at end of file diff --git a/frontend/src/settingsPage/SettingsPage.tsx b/frontend/src/settingsPage/SettingsPage.tsx index 50dc58c..a5dd0de 100644 --- a/frontend/src/settingsPage/SettingsPage.tsx +++ b/frontend/src/settingsPage/SettingsPage.tsx @@ -1,6 +1,6 @@ import React, { FC } from "react"; import { ButtonWithLoadingState } from "./PopulateUserDatabaseButton"; -import { populateAdditionalAlbumDetails, populateUniversalGenreList, populateUserData } from "../api"; +import { populateAdditionalAlbumDetails, populateUniversalGenreList, populateUserAlbumGenres, populateUserData } from "../api"; export const SettingsPage: FC = () => { return ( @@ -10,6 +10,7 @@ export const SettingsPage: FC = () => { +
); From a1ad5a4f6f630491fa0c759cde66ceb236fd4fbd Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Wed, 4 Sep 2024 08:47:34 +0100 Subject: [PATCH 06/24] Populate album_genres on playlist manager page load --- backend/src/controllers/database.py | 2 +- backend/src/database/crud/album.py | 4 ++-- backend/src/spotify.py | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/controllers/database.py b/backend/src/controllers/database.py index 0123153..b52d9da 100644 --- a/backend/src/controllers/database.py +++ b/backend/src/controllers/database.py @@ -140,7 +140,7 @@ def populate_album_genres_by_user_id( for idx, db_album in enumerate(albums): print("\033[A \033[A") print(f"processing album {idx} of {len(albums)}, skipped {skip_count}") - if get_album_genres(db_album) != []: + if get_album_genres(db_album.id) != []: skip_count += 1 continue album_artists = get_album_artists(db_album) diff --git a/backend/src/database/crud/album.py b/backend/src/database/crud/album.py index 3deb501..ad663a6 100644 --- a/backend/src/database/crud/album.py +++ b/backend/src/database/crud/album.py @@ -59,12 +59,12 @@ def update_album(album: Album): return album -def get_album_genres(album: DbAlbum) -> List[str]: +def get_album_genres(album_id: str) -> List[str]: query = ( DbGenre.select() .join(AlbumGenreRelationship) .join(DbAlbum) - .where(DbAlbum.id == album.id) + .where(DbAlbum.id == album_id) ) return list(query) diff --git a/backend/src/spotify.py b/backend/src/spotify.py index 9f00a1c..14c4abb 100644 --- a/backend/src/spotify.py +++ b/backend/src/spotify.py @@ -4,6 +4,7 @@ import urllib.parse from typing import List, Optional from flask import Response, make_response, redirect +from src.database.crud.album import get_album_genres from src.dataclasses.album import Album from src.dataclasses.playback_info import PlaybackInfo, PlaylistProgression from src.dataclasses.playback_request import ( @@ -263,6 +264,8 @@ def get_playlist_album_info(self, access_token, id) -> List[Album]: for track in playlist_tracks: if track.track.album not in playlist_albums: playlist_albums.append(track.track.album) + for album in playlist_albums: + album.genres = [genre.name for genre in get_album_genres(album.id)] return playlist_albums def get_playlist_tracks(self, access_token, id: str): From 36a96f090d61cd85f3084ab1ffb4f94fff86e8c5 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Wed, 4 Sep 2024 08:50:00 +0100 Subject: [PATCH 07/24] Upgrade flask-cors --- backend/poetry.lock | 8 ++++---- backend/pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/poetry.lock b/backend/poetry.lock index 73a0ff6..2bbc00b 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -208,13 +208,13 @@ dotenv = ["python-dotenv"] [[package]] name = "flask-cors" -version = "4.0.1" +version = "5.0.0" description = "A Flask extension adding a decorator for CORS support" optional = false python-versions = "*" files = [ - {file = "Flask_Cors-4.0.1-py2.py3-none-any.whl", hash = "sha256:f2a704e4458665580c074b714c4627dd5a306b333deb9074d0b1794dfa2fb677"}, - {file = "flask_cors-4.0.1.tar.gz", hash = "sha256:eeb69b342142fdbf4766ad99357a7f3876a2ceb77689dc10ff912aac06c389e4"}, + {file = "Flask_Cors-5.0.0-py2.py3-none-any.whl", hash = "sha256:b9e307d082a9261c100d8fb0ba909eec6a228ed1b60a8315fd85f783d61910bc"}, + {file = "flask_cors-5.0.0.tar.gz", hash = "sha256:5aadb4b950c4e93745034594d9f3ea6591f734bb3662e16e255ffbf5e89c88ef"}, ] [package.dependencies] @@ -691,4 +691,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "888b0d4108a83d5b35314ff2b10f25256013f5a2283f282759024c4581f02791" +content-hash = "8c0f2ef1be23afbe42bdcf1de578a9fbe6ed05886d9f7811d0dead81b9aa8760" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e10e26f..9038c55 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,7 +13,7 @@ packages = [ python = "^3.10" flask = "^3.0.3" python-dotenv = "^1.0.0" -flask-cors = "^4.0.1" +flask-cors = "^5.0.0" pydantic = "^2.6.4" requests = "^2.32.3" gunicorn = "^22.0.0" From 447e02b474ab7fdcd8083f516c1c883cc0c8c5ed Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Thu, 5 Sep 2024 08:47:47 +0100 Subject: [PATCH 08/24] Use db for fetching all playlists --- backend/src/app.py | 2 + backend/src/controllers/music_data.py | 78 ++++++++++++++++++++ backend/src/database/crud/playlist.py | 34 ++++++++- frontend/src/api/index.ts | 6 +- frontend/src/interfaces/Playlist.ts | 2 +- frontend/src/playlistTable/PlaylistTable.tsx | 4 +- 6 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 backend/src/controllers/music_data.py diff --git a/backend/src/app.py b/backend/src/app.py index 5dd8f6a..7ce202f 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -2,6 +2,7 @@ from flask_cors import CORS from src.controllers.database import database_controller from src.controllers.spotify import spotify_controller +from src.controllers.music_data import music_controller from src.exceptions.Unauthorized import UnauthorizedException from src.flask_config import Config from src.musicbrainz import MusicbrainzClient @@ -48,6 +49,7 @@ def handle_unauthorized_exception(_): app.register_blueprint(auth_controller(spotify=spotify)) app.register_blueprint(spotify_controller(spotify=spotify)) + app.register_blueprint(music_controller(spotify=spotify)) app.register_blueprint( database_controller(spotify=spotify, musicbrainz=musicbrainz) ) diff --git a/backend/src/controllers/music_data.py b/backend/src/controllers/music_data.py new file mode 100644 index 0000000..b185ecc --- /dev/null +++ b/backend/src/controllers/music_data.py @@ -0,0 +1,78 @@ +from logging import Logger +from flask import Blueprint, jsonify, make_response, request +from src.database.crud.playlist import get_user_playlists +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 music_controller(spotify: SpotifyClient): + music_controller = Blueprint( + name="music_controller", import_name=__name__, url_prefix="/music" + ) + + @music_controller.route("playlists") + def index(): + user_id = request.cookies.get("user_id") + limit = request.args.get("limit", type=int) + offset = request.args.get("offset", type=int) + search = request.args.get("search") + sort_by = request.args.get("sort_by") + desc = request.args.get("desc") == "True" + return jsonify( + get_user_playlists( + user_id=user_id, + limit=limit, + offset=offset, + search=search, + sort_by=sort_by, + desc=desc, + as_dicts=True, + ) + ) + + @music_controller.route("playlist/", methods=["GET"]) + def get_playlist(id): + access_token = request.cookies.get("spotify_access_token") + playlist = spotify.get_playlist(access_token=access_token, id=id) + return playlist.model_dump() + + @music_controller.route("playlist/", methods=["POST"]) + def post_edit_playlist(id): + access_token = request.cookies.get("spotify_access_token") + name = request.json.get("name") + description = request.json.get("description") + spotify.update_playlist( + access_token=access_token, + id=id, + name=name, + description=description, + ) + return make_response("playlist updated", 204) + + @music_controller.route("playlist//albums", methods=["GET"]) + def get_playlist_album_info(id): + access_token = request.cookies.get("spotify_access_token") + return [ + album.model_dump() + for album in spotify.get_playlist_album_info( + access_token=access_token, id=id + ) + ] + + @music_controller.route("find_associated_playlists", methods=["POST"]) + def find_associated_playlists(): + access_token = request.cookies.get("spotify_access_token") + user_id = request.cookies.get("user_id") + playlist = Playlist.model_validate(request.json) + associated_playlists = spotify.find_associated_playlists( + user_id=user_id, access_token=access_token, playlist=playlist + ) + return [ + associated_playlist.model_dump() + for associated_playlist in associated_playlists + ] + + return music_controller diff --git a/backend/src/database/crud/playlist.py b/backend/src/database/crud/playlist.py index 9d57b48..bcdb50e 100644 --- a/backend/src/database/crud/playlist.py +++ b/backend/src/database/crud/playlist.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from src.database.crud.album import create_album_or_none from src.database.models import DbAlbum, DbPlaylist, DbUser, PlaylistAlbumRelationship from src.dataclasses.album import Album @@ -46,8 +46,36 @@ def update_playlist(playlist: Playlist, albums: List[Album]): return playlist -def get_user_playlists(user_id: str) -> List[DbPlaylist]: - return DbPlaylist.select().where(DbPlaylist.user == user_id).execute() +def get_user_playlists( + user_id: str, + limit: Optional[int] = None, + offset: Optional[int] = None, + search: Optional[str] = None, + sort_by: Optional[str] = None, + desc: bool = True, + as_dicts: bool = False, +) -> List[DbPlaylist]: + query = DbPlaylist.select().where(DbPlaylist.user == user_id) + + if search: + query = query.where(DbPlaylist.name.contains(search)) + + if sort_by: + sort_field = getattr(DbPlaylist, sort_by) + if desc: + query = query.order_by(sort_field.desc()) + else: + query = query.order_by(sort_field.asc()) + + if limit is not None: + query = query.limit(limit) + if offset is not None: + query = query.offset(offset) + + if as_dicts: + return list(query.dicts()) + else: + return list(query.execute()) def get_playlist_albums(playlist_id: str) -> List[DbAlbum]: diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 09f9aec..a597b8d 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -43,7 +43,7 @@ export const getPlaylists = async ( offset: number, limit: number, ): Promise => { - const endpoint = `spotify/playlists?limit=${encodeURIComponent( + const endpoint = `music/playlists?limit=${encodeURIComponent( limit, )}&offset=${encodeURIComponent(offset)}`; return jsonRequest(endpoint, RequestMethod.GET); @@ -54,7 +54,7 @@ export const addPlaylist = async (playlist: Playlist): Promise => { }; export const getPlaylist = async (id: string): Promise => { - return jsonRequest(`spotify/edit-playlist/${id}`, RequestMethod.GET); + return jsonRequest(`music/playlist/${id}`, RequestMethod.GET); }; export const updatePlaylist = async (playlist: Playlist): Promise => { @@ -76,7 +76,7 @@ export const getPlaylistAlbums = async ( playlistId: string, ): Promise => { return jsonRequest( - `spotify/playlist/${playlistId}/albums`, + `music/playlist/${playlistId}/albums`, RequestMethod.GET, ); }; diff --git a/frontend/src/interfaces/Playlist.ts b/frontend/src/interfaces/Playlist.ts index 169e595..c5325fb 100644 --- a/frontend/src/interfaces/Playlist.ts +++ b/frontend/src/interfaces/Playlist.ts @@ -18,7 +18,7 @@ export interface Playlist { }; href: string; id: string; - images: Image[]; + image_url: string; owner: User; public: false; snapshot_id: string; diff --git a/frontend/src/playlistTable/PlaylistTable.tsx b/frontend/src/playlistTable/PlaylistTable.tsx index 131946a..4179336 100644 --- a/frontend/src/playlistTable/PlaylistTable.tsx +++ b/frontend/src/playlistTable/PlaylistTable.tsx @@ -29,9 +29,9 @@ const PlaylistTable: FC = ({ playlists }) => { id: "image", size: 20, cell: ({ row }) => { - if (row.original.images?.[0]?.url) { + if (row.original.image_url) { return ( - + ); } return ; From 05ee50a96ec7c0dc2d084161767b04e9345da1c1 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Fri, 6 Sep 2024 11:40:09 +0100 Subject: [PATCH 09/24] WIP --- backend/src/controllers/database.py | 4 +- backend/src/controllers/music_data.py | 56 ++++++-- backend/src/database/crud/playlist.py | 134 +++++++++++++++++- backend/src/database/models.py | 2 + frontend/eslint.config.js | 6 +- frontend/package-lock.json | 41 +++++- frontend/package.json | 5 +- frontend/public/index.html | 2 +- frontend/src/MainPage.tsx | 57 +++----- frontend/src/api/index.ts | 26 +++- frontend/src/components/AlbumIcon.tsx | 18 ++- frontend/src/components/ArtistIcon.tsx | 9 +- frontend/src/components/Box.tsx | 2 +- frontend/src/components/Button.tsx | 2 +- frontend/src/components/Carousel/Carousel.tsx | 103 ++++++++++++++ frontend/src/components/Input.tsx | 2 +- frontend/src/components/LinkButton.tsx | 2 +- frontend/src/components/LoadingSpinner.tsx | 2 +- .../src/components/Playlist/PlaylistSlide.tsx | 16 +++ frontend/src/components/PlaylistIcon.tsx | 5 +- frontend/src/components/SearchBar.tsx | 38 +++++ frontend/src/components/SongIcon.tsx | 6 +- frontend/src/interfaces/Album.ts | 3 +- frontend/src/interfaces/Image.ts | 5 - frontend/src/interfaces/Playlist.ts | 1 - .../AlbumList/AlbumContainer.tsx | 6 +- .../src/playlistExplorer/PlaylistExplorer.tsx | 4 +- .../playlistTable/DeletePlaylistButton.tsx | 2 +- .../src/playlistTable/EditPlaylistButton.tsx | 6 +- frontend/src/playlistTable/PlaylistTable.tsx | 21 +-- frontend/src/presentational/Header/Header.tsx | 4 +- .../src/presentational/PlaybackFooter.tsx | 8 +- .../PopulateUserDatabaseButton.tsx | 2 +- frontend/tailwind.config.ts | 49 ++----- 34 files changed, 498 insertions(+), 151 deletions(-) create mode 100644 frontend/src/components/Carousel/Carousel.tsx create mode 100644 frontend/src/components/Playlist/PlaylistSlide.tsx create mode 100644 frontend/src/components/SearchBar.tsx delete mode 100644 frontend/src/interfaces/Image.ts diff --git a/backend/src/controllers/database.py b/backend/src/controllers/database.py index b52d9da..1228b59 100644 --- a/backend/src/controllers/database.py +++ b/backend/src/controllers/database.py @@ -13,7 +13,7 @@ get_playlist_albums, get_playlist_by_id_or_none, get_user_playlists, - update_playlist, + update_playlist_with_albums, ) from src.database.crud.user import get_or_create_user from src.musicbrainz import MusicbrainzClient @@ -59,7 +59,7 @@ def populate_user(): access_token=access_token, id=simplified_playlist.id ), ] - update_playlist(playlist, albums) + update_playlist_with_albums(playlist, albums) return make_response("Playlist data populated", 201) diff --git a/backend/src/controllers/music_data.py b/backend/src/controllers/music_data.py index b185ecc..ef74f6a 100644 --- a/backend/src/controllers/music_data.py +++ b/backend/src/controllers/music_data.py @@ -1,6 +1,14 @@ from logging import Logger from flask import Blueprint, jsonify, make_response, request -from src.database.crud.playlist import get_user_playlists +from src.database.crud.playlist import ( + get_playlist_albums, + get_playlist_albums_with_genres, + get_playlist_by_id_or_none, + get_recent_user_playlists, + get_user_playlists, + update_playlist_info, + update_playlist_with_albums, +) from src.dataclasses.playback_info import PlaybackInfo from src.dataclasses.playback_request import StartPlaybackRequest from src.dataclasses.playlist import Playlist @@ -33,17 +41,38 @@ def index(): ) ) + @music_controller.route("playlists/recent") + def recent_playlists(): + user_id = request.cookies.get("user_id") + limit = request.args.get("limit", type=int) + offset = request.args.get("offset", type=int) + search = request.args.get("search") + + return jsonify( + get_recent_user_playlists( + user_id=user_id, + limit=limit, + offset=offset, + search=search + ) + ) + @music_controller.route("playlist/", methods=["GET"]) def get_playlist(id): - access_token = request.cookies.get("spotify_access_token") - playlist = spotify.get_playlist(access_token=access_token, id=id) - return playlist.model_dump() + db_playlist = get_playlist_by_id_or_none(id) + if db_playlist is not None: + return jsonify(db_playlist.__data__) + else: + access_token = request.cookies.get("spotify_access_token") + playlist = spotify.get_playlist(access_token=access_token, id=id) + return playlist.model_dump() @music_controller.route("playlist/", methods=["POST"]) def post_edit_playlist(id): access_token = request.cookies.get("spotify_access_token") name = request.json.get("name") description = request.json.get("description") + update_playlist_info(id=id, name=name, description=description) spotify.update_playlist( access_token=access_token, id=id, @@ -54,13 +83,18 @@ def post_edit_playlist(id): @music_controller.route("playlist//albums", methods=["GET"]) def get_playlist_album_info(id): - access_token = request.cookies.get("spotify_access_token") - return [ - album.model_dump() - for album in spotify.get_playlist_album_info( - access_token=access_token, id=id - ) - ] + db_playlist = get_playlist_by_id_or_none(id) + if db_playlist is not None: + album_info_list = get_playlist_albums_with_genres(id) + return jsonify(album_info_list) + else: + access_token = request.cookies.get("spotify_access_token") + return [ + album.model_dump() + for album in spotify.get_playlist_album_info( + access_token=access_token, id=id + ) + ] @music_controller.route("find_associated_playlists", methods=["POST"]) def find_associated_playlists(): diff --git a/backend/src/database/crud/playlist.py b/backend/src/database/crud/playlist.py index bcdb50e..2b22358 100644 --- a/backend/src/database/crud/playlist.py +++ b/backend/src/database/crud/playlist.py @@ -1,8 +1,21 @@ from typing import List, Optional from src.database.crud.album import create_album_or_none -from src.database.models import DbAlbum, DbPlaylist, DbUser, PlaylistAlbumRelationship +from src.database.models import ( + AlbumArtistRelationship, + AlbumGenreRelationship, + DbAlbum, + DbArtist, + DbGenre, + DbPlaylist, + DbUser, + PlaylistAlbumRelationship, + peewee_model_to_dict, +) from src.dataclasses.album import Album from src.dataclasses.playlist import Playlist +from peewee import fn +import re +from datetime import datetime def get_playlist_by_id_or_none(id: str): @@ -27,7 +40,34 @@ def create_playlist(playlist: Playlist, albums: List[Album], user: DbUser): return playlist -def update_playlist(playlist: Playlist, albums: List[Album]): +def update_playlist_info( + id: str, + name: Optional[str] = None, + description: Optional[str] = None, + snapshot_id: Optional[str] = None, + uri: Optional[str] = None, +) -> DbPlaylist | None: + update_data = {} + if name is not None: + update_data["name"] = name + if description is not None: + update_data["description"] = description + if snapshot_id is not None: + update_data["snapshot_id"] = snapshot_id + if uri is not None: + update_data["uri"] = uri + + if update_data: + query = DbPlaylist.update(update_data).where(DbPlaylist.id == id) + query.execute() + + updated_playlist = DbPlaylist.get_by_id(id) + return updated_playlist + + return None + + +def update_playlist_with_albums(playlist: Playlist, albums: List[Album]): playlist = DbPlaylist.update( id=playlist.id, description=playlist.description, @@ -78,6 +118,48 @@ def get_user_playlists( return list(query.execute()) +def get_recent_user_playlists( + user_id: str, + limit: Optional[int] = None, + offset: Optional[int] = None, + search: Optional[str] = None, +) -> List[DbPlaylist]: + + # Use TO_DATE function in PostgreSQL to convert the extracted date into a proper date + query = ( + DbPlaylist.select() + .where( + ( + DbPlaylist.name.startswith("New Albums") + | DbPlaylist.name.startswith("Best Albums") + ) + ) + # Convert the date string into a proper date format for ordering + .order_by(fn.TO_DATE(fn.RIGHT(DbPlaylist.name, 8), "DD/MM/YY").desc()) + .limit(limit) + .offset(offset) + ) + + if search: + query = query.where(DbPlaylist.name.contains(search)) + + playlists = [] + for playlist in query: + # Add playlist to the result with parsed date + playlists.append( + { + "id": playlist.id, + "name": playlist.name, + "description": playlist.description, + "image_url": playlist.image_url, + "user_id": playlist.user.id, + "snapshot_id": playlist.snapshot_id, + "uri": playlist.uri, + } + ) + return playlists + + def get_playlist_albums(playlist_id: str) -> List[DbAlbum]: query = ( DbAlbum.select() @@ -86,3 +168,51 @@ def get_playlist_albums(playlist_id: str) -> List[DbAlbum]: .where(DbPlaylist.id == playlist_id) ) return list(query) + + +def get_playlist_albums_with_genres(playlist_id: str) -> List[dict]: + # Step 1: Retrieve all albums associated with the given playlist + albums_query = ( + DbAlbum.select() + .join( + PlaylistAlbumRelationship, + on=(PlaylistAlbumRelationship.album == DbAlbum.id), + ) + .join(DbPlaylist, on=(PlaylistAlbumRelationship.playlist == DbPlaylist.id)) + .where(DbPlaylist.id == playlist_id) + ) + + # Step 2: Retrieve genres and artists for each album + albums_with_details = [] + for album in albums_query: + genres = ( + DbGenre.select(DbGenre.name) + .join( + AlbumGenreRelationship, on=(AlbumGenreRelationship.genre == DbGenre.id) + ) + .where(AlbumGenreRelationship.album == album.id) + .execute() + ) + + artists = ( + DbArtist.select() + .join( + AlbumArtistRelationship, + on=(AlbumArtistRelationship.artist == DbArtist.id), + ) + .where(AlbumArtistRelationship.album == album.id) + ) + + album_details = { + "id": album.id, + "name": album.name, + "uri": album.uri, + "image_url": album.image_url, + "release_date": album.release_date, + "total_tracks": album.total_tracks, + "genres": [genre.name for genre in genres], + "artists": [peewee_model_to_dict(artist) for artist in artists], + } + albums_with_details.append(album_details) + + return albums_with_details diff --git a/backend/src/database/models.py b/backend/src/database/models.py index a252f3f..254c6e8 100644 --- a/backend/src/database/models.py +++ b/backend/src/database/models.py @@ -10,6 +10,8 @@ database = PostgresqlDatabase(Config().DB_CONNECTION_STRING) +def peewee_model_to_dict(model_instance): + return {field: getattr(model_instance, field) for field in model_instance._meta.fields} class BaseModel(Model): class Meta: diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e43726..4e2eece 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -10,9 +10,9 @@ export default [ pluginJs.configs.recommended, ...tseslint.configs.recommended, pluginReact.configs.flat.recommended, - { - rules: {"unused-imports/no-unused-imports": "error"} - }, + // { + // rules: {"unused-imports/no-unused-imports": "error"} + // }, { ignores: ["public/bundle.js"], }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c06b897..3581e6a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@tanstack/react-table": "^8.20.1", "@tanstack/react-query": "^5.51.21", + "@tanstack/react-table": "^8.20.1", "dotenv": "^16.4.5", + "embla-carousel-react": "^8.2.1", + "lodash": "^4.17.21", "moment": "^2.30.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -21,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^9.9.0", + "@types/lodash": "^4.17.7", "@types/node": "^20.12.7", "@types/react-dom": "^18.2.18", "esbuild": "^0.23.1", @@ -781,6 +784,12 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true + }, "node_modules/@types/node": { "version": "20.12.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", @@ -1673,6 +1682,31 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/embla-carousel": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.2.1.tgz", + "integrity": "sha512-9mTDtyMZJhFuuW5pixhTT4iLiJB1l3dH3IpXUKCsgLlRlHCiySf/wLKy5xIAzmxIsokcQ50xea8wi7BCt0+Rxg==" + }, + "node_modules/embla-carousel-react": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.2.1.tgz", + "integrity": "sha512-YKtARk101mp00Zb6UAFkkvK+5XRo92LAtO9xLFeDnQ/XU9DqFhKnRy1CedRRj0/RSk6MTFDx3MqOQue3gJj9DA==", + "dependencies": { + "embla-carousel": "8.2.1", + "embla-carousel-reactive-utils": "8.2.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.2.1.tgz", + "integrity": "sha512-LXMVOOyv09ZKRxRQXYMX1FpVGcypsuxdcidNcNlBQUN2mK7hkmjVFQwwhfnnY39KMi88XYnYPBgMxfTe0vxSrA==", + "peerDependencies": { + "embla-carousel": "8.2.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -3280,6 +3314,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.foreach": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6de74ee..2af6727 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "homepage": "https://github.com/CalPinSW/playlist-manager#readme", "devDependencies": { "@eslint/js": "^9.9.0", + "@types/lodash": "^4.17.7", "@types/node": "^20.12.7", "@types/react-dom": "^18.2.18", "esbuild": "^0.23.1", @@ -41,9 +42,11 @@ "typescript-eslint": "^8.2.0" }, "dependencies": { - "@tanstack/react-table": "^8.20.1", "@tanstack/react-query": "^5.51.21", + "@tanstack/react-table": "^8.20.1", "dotenv": "^16.4.5", + "embla-carousel-react": "^8.2.1", + "lodash": "^4.17.21", "moment": "^2.30.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/public/index.html b/frontend/public/index.html index fb86ea9..522bbdc 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,5 +1,5 @@ - + { const { isMobileView } = useWindowSize(); - + const [search, setSearch] = useState("") const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: isMobileView ? 5 : 8, @@ -25,7 +24,7 @@ export const Index: FC = () => { useEffect(() => { const previousIndex = pagination.pageSize * pagination.pageIndex; - const pageSize = isMobileView ? 8 : 8; + const pageSize = isMobileView ? 10 : 10; const newIndex = Math.floor(previousIndex / pageSize); setPagination({ @@ -34,45 +33,29 @@ export const Index: FC = () => { }); }, [isMobileView]); - const onClickNext = () => { - setPagination((state) => ({ - pageSize: state.pageSize, - pageIndex: state.pageIndex + state.pageSize, - })); - }; - const onClickPrevious = () => { - setPagination((state) => ({ - pageSize: state.pageSize, - pageIndex: Math.max(state.pageIndex - state.pageSize, 0), - })); - }; - const { isLoading, error, data } = useQuery({ - queryKey: ["playlists", pagination], + const recentQuery = useQuery({ + queryKey: ["playlists", pagination, search], queryFn: () => { - return getPlaylists(pagination.pageIndex, pagination.pageSize); + return getRecentPlaylists(search, pagination.pageIndex, pagination.pageSize); }, }); - if (isLoading || !data) return "Loading..."; - - if (error) return "An error has occurred: " + error.message; + const allQuery = useQuery({ + queryKey: ["playlists", pagination, search], + queryFn: () => { + return getPlaylists(search, pagination.pageIndex, pagination.pageSize); + }, + }); return (
+ + + {recentQuery.data && } + - -
- - -
Previous
-
- - - -
Next
-
-
+ {allQuery.data && }
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a597b8d..5551cac 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -39,13 +39,31 @@ export const getCurrentUserDetails = async (): Promise => { ); }; +export const getRecentPlaylists = async ( + search: string, + offset: number, + limit: number, +): Promise => { + const searchParams = new URLSearchParams(); + searchParams.append("limit", String(limit)); + searchParams.append("offset", String(offset)); + if (search !== "") {searchParams.append("search", search);} + searchParams.toString(); // "type=all&query=coins" + const endpoint = `music/playlists/recent?${searchParams.toString()}`; + return jsonRequest(endpoint, RequestMethod.GET); +}; + export const getPlaylists = async ( + search: string, offset: number, limit: number, ): Promise => { - const endpoint = `music/playlists?limit=${encodeURIComponent( - limit, - )}&offset=${encodeURIComponent(offset)}`; + const searchParams = new URLSearchParams(); + searchParams.append("limit", String(limit)); + searchParams.append("offset", String(offset)); + if (search !== "") {searchParams.append("search", search);} + searchParams.toString(); // "type=all&query=coins" + const endpoint = `music/playlists?${searchParams.toString()}`; return jsonRequest(endpoint, RequestMethod.GET); }; @@ -59,7 +77,7 @@ export const getPlaylist = async (id: string): Promise => { export const updatePlaylist = async (playlist: Playlist): Promise => { return jsonRequest( - `spotify/edit-playlist/${playlist.id}`, + `music/playlist/${playlist.id}`, RequestMethod.POST, playlist, ); diff --git a/frontend/src/components/AlbumIcon.tsx b/frontend/src/components/AlbumIcon.tsx index 1433597..871e3ad 100644 --- a/frontend/src/components/AlbumIcon.tsx +++ b/frontend/src/components/AlbumIcon.tsx @@ -5,34 +5,42 @@ const AlbumIcon: FC> = (props) => { diff --git a/frontend/src/components/ArtistIcon.tsx b/frontend/src/components/ArtistIcon.tsx index 8b19026..4e99e15 100644 --- a/frontend/src/components/ArtistIcon.tsx +++ b/frontend/src/components/ArtistIcon.tsx @@ -3,9 +3,10 @@ import React, { FC } from "react"; const ArtistIcon: FC> = (props) => { return ( - - + + > = (props) => { strokeLinejoin="round" /> > = (props) => { - + diff --git a/frontend/src/components/Box.tsx b/frontend/src/components/Box.tsx index e02474d..56779fe 100644 --- a/frontend/src/components/Box.tsx +++ b/frontend/src/components/Box.tsx @@ -5,7 +5,7 @@ const Box: FC< > = ({className, ...props}) => { return (
{props.children} diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index e3f61e9..b1346e0 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -9,7 +9,7 @@ const Button: FC< return (
); diff --git a/frontend/src/components/LinkButton.tsx b/frontend/src/components/LinkButton.tsx index 3af2405..0b4afb2 100644 --- a/frontend/src/components/LinkButton.tsx +++ b/frontend/src/components/LinkButton.tsx @@ -9,7 +9,7 @@ const Button: FC< return ( ); }; diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx index 822fe9f..ac5b1b5 100644 --- a/frontend/src/components/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner.tsx @@ -1,6 +1,6 @@ import React from "react" -const LoadingSpinner = ({}) => { +const LoadingSpinner = () => { return (
+ : } +
+ {playlist.name} +
+ + ) + + export default PlaylistSlide; \ No newline at end of file diff --git a/frontend/src/components/PlaylistIcon.tsx b/frontend/src/components/PlaylistIcon.tsx index 0200742..e640cc9 100644 --- a/frontend/src/components/PlaylistIcon.tsx +++ b/frontend/src/components/PlaylistIcon.tsx @@ -7,8 +7,9 @@ const PlaylistIcon: FC> = (props) => { xmlns="http://www.w3.org/2000/svg" {...props} > - - + + ); }; diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx new file mode 100644 index 0000000..23e6cba --- /dev/null +++ b/frontend/src/components/SearchBar.tsx @@ -0,0 +1,38 @@ +import React, { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from "react"; +import { debounce } from "lodash" + +interface SearchBarProps { + search: string, + setSearch: Dispatch> +} + +const SearchBar: FC = ({ + search, + setSearch +}) => { + const [displaySearch, setDisplaySearch] = useState(search); + const debouncedSetSearch = useMemo(() => debounce(setSearch, 500), [setSearch]); + + useEffect(() => { + // Update the debounced search value whenever localSearch changes + debouncedSetSearch(displaySearch); + + // Cleanup the debounce on unmount + return () => { + debouncedSetSearch.cancel(); + }; + }, [displaySearch, debouncedSetSearch]); + + const handleChange = (event: React.ChangeEvent) => { + setDisplaySearch(event.target.value); + }; + + + return ( +
+
Search
+ +
+)}; + +export default SearchBar; diff --git a/frontend/src/components/SongIcon.tsx b/frontend/src/components/SongIcon.tsx index 5fe742c..15348ff 100644 --- a/frontend/src/components/SongIcon.tsx +++ b/frontend/src/components/SongIcon.tsx @@ -7,9 +7,9 @@ const SongIcon: FC> = (props) => { xmlns="http://www.w3.org/2000/svg" {...props} > - - - + + + ); }; diff --git a/frontend/src/interfaces/Album.ts b/frontend/src/interfaces/Album.ts index 9ab9c92..1139ea2 100644 --- a/frontend/src/interfaces/Album.ts +++ b/frontend/src/interfaces/Album.ts @@ -1,5 +1,4 @@ import { Artist } from "./Artist"; -import { Image } from "./Image"; export interface Album { album_type: string; @@ -7,7 +6,7 @@ export interface Album { available_markets: string[]; href: string; id: string; - images: Image[]; + image_url: string; name: string; release_date: string; release_date_precision: string; diff --git a/frontend/src/interfaces/Image.ts b/frontend/src/interfaces/Image.ts deleted file mode 100644 index 4010b87..0000000 --- a/frontend/src/interfaces/Image.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Image { - url: string; - height: number; - width: number; -} diff --git a/frontend/src/interfaces/Playlist.ts b/frontend/src/interfaces/Playlist.ts index c5325fb..92fac5b 100644 --- a/frontend/src/interfaces/Playlist.ts +++ b/frontend/src/interfaces/Playlist.ts @@ -1,5 +1,4 @@ import { User } from "./User"; -import { Image } from "./Image"; import { Track } from "./Track"; export interface PlaylistTrack { diff --git a/frontend/src/playlistExplorer/AlbumList/AlbumContainer.tsx b/frontend/src/playlistExplorer/AlbumList/AlbumContainer.tsx index 5252fb6..b93995f 100644 --- a/frontend/src/playlistExplorer/AlbumList/AlbumContainer.tsx +++ b/frontend/src/playlistExplorer/AlbumList/AlbumContainer.tsx @@ -145,10 +145,10 @@ interface AlbumCoverProps { } export const AlbumCover: FC = ({ album, blur }) => { - if (album.images[0].url) { + if (album.image_url) { return ( = ({ album, blur }) => { > ); } - return ; + return ; }; diff --git a/frontend/src/playlistExplorer/PlaylistExplorer.tsx b/frontend/src/playlistExplorer/PlaylistExplorer.tsx index beea49c..e04144b 100644 --- a/frontend/src/playlistExplorer/PlaylistExplorer.tsx +++ b/frontend/src/playlistExplorer/PlaylistExplorer.tsx @@ -106,14 +106,14 @@ export const PlaylistExplorer: FC = () => { >

Track View

Album View diff --git a/frontend/src/playlistTable/DeletePlaylistButton.tsx b/frontend/src/playlistTable/DeletePlaylistButton.tsx index 0307c4b..00793eb 100644 --- a/frontend/src/playlistTable/DeletePlaylistButton.tsx +++ b/frontend/src/playlistTable/DeletePlaylistButton.tsx @@ -8,7 +8,7 @@ interface IDeletePlaylistButton { const DeletePlaylistButton: FC = ({ onClick }) => { return ( ); }; diff --git a/frontend/src/playlistTable/EditPlaylistButton.tsx b/frontend/src/playlistTable/EditPlaylistButton.tsx index 4a2bb74..60b851b 100644 --- a/frontend/src/playlistTable/EditPlaylistButton.tsx +++ b/frontend/src/playlistTable/EditPlaylistButton.tsx @@ -8,9 +8,9 @@ interface IEditPlaylistButton { const EditPlaylistButton: FC = ({ playlistId }) => { return ( - - - + + + ); }; diff --git a/frontend/src/playlistTable/PlaylistTable.tsx b/frontend/src/playlistTable/PlaylistTable.tsx index 4179336..05f65eb 100644 --- a/frontend/src/playlistTable/PlaylistTable.tsx +++ b/frontend/src/playlistTable/PlaylistTable.tsx @@ -12,12 +12,14 @@ import { import PlaylistIcon from "./PlaylistIcon"; import { deletePlaylist } from "../api"; import useWindowSize from "../hooks/useWindowSize"; +import { UseQueryResult } from "@tanstack/react-query"; +import LoadingSpinner from "../components/LoadingSpinner"; interface IPlaylistTable { - playlists: Playlist[]; + playlistsQuery: UseQueryResult } -const PlaylistTable: FC = ({ playlists }) => { +const PlaylistTable: FC = ({ playlistsQuery }) => { const { isMobileView } = useWindowSize(); const onDeletePlaylistClick = (playlist: Playlist) => { @@ -27,25 +29,25 @@ const PlaylistTable: FC = ({ playlists }) => { const defaultColumns = [ columnHelper.display({ id: "image", - size: 20, + size: 50, cell: ({ row }) => { if (row.original.image_url) { return ( ); } - return ; + return ; }, }), columnHelper.accessor("name", { - size: 200, + size: 150, cell: (info) => info.getValue(), }), ...(isMobileView ? [] : [ columnHelper.accessor("description", { - size: 200, + size: 150, cell: (info) => info.getValue(), }), ]), @@ -71,13 +73,13 @@ const PlaylistTable: FC = ({ playlists }) => { const table = useReactTable({ columns: defaultColumns, - data: playlists, + data: playlistsQuery.data ?? [], getCoreRowModel: getCoreRowModel(), }); return (
- +
{table.getHeaderGroups().map((headerGroup) => ( @@ -102,7 +104,8 @@ const PlaylistTable: FC = ({ playlists }) => { ))} - {table.getRowModel().rows.map((row) => ( + {playlistsQuery.isLoading ?
: + table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => (
{ }); return ( -
+
Playlist Manager diff --git a/frontend/src/presentational/PlaybackFooter.tsx b/frontend/src/presentational/PlaybackFooter.tsx index bf8bd78..57a1a9e 100644 --- a/frontend/src/presentational/PlaybackFooter.tsx +++ b/frontend/src/presentational/PlaybackFooter.tsx @@ -17,7 +17,7 @@ const PlaybackFooter: FC = () => { } return ( -
+
)}
- - ); -}; - -interface AlbumActionsModalContentProps { - album: Album; - contextPlaylist: Playlist - associatedPlaylists: Playlist[]; - closeModal: () => void; -} -const AlbumActionsModalContent: FC = ({ - album, - contextPlaylist, - associatedPlaylists, - closeModal, -}) => { - const addAlbumToAssociatedPlaylist = (targetPlaylist: Playlist): void => { - addAlbumToPlaylist(targetPlaylist.id, album.id); - closeModal(); - }; - return ( -
-
-

Actions:

- -
-
- {associatedPlaylists.map((associatedPlaylist) => ( - - ))} - -
-
- ); -}; - -export const AlbumInfo: FC = ({ album }) => { - return ( -
- -
-
-
{album.name}
-
{album.artists.map((artist) => artist.name).join(", ")}
-
{album.genres}
-
{album.label}
-
{album.popularity}
-
-
-
); }; diff --git a/frontend/src/playlistExplorer/AlbumList/AlbumList.tsx b/frontend/src/playlistExplorer/AlbumList/AlbumList.tsx index 2784323..19c7ed4 100644 --- a/frontend/src/playlistExplorer/AlbumList/AlbumList.tsx +++ b/frontend/src/playlistExplorer/AlbumList/AlbumList.tsx @@ -1,8 +1,10 @@ -import React, { FC } from "react"; +import React, { FC, useState } from "react"; import { Album } from "../../interfaces/Album"; import { AlbumContainer } from "./AlbumContainer"; -import Box from "../../components/Box"; import { Playlist } from "../../interfaces/Playlist"; +import Carousel from "../../components/Carousel/Carousel"; +import Button from "../../components/Button"; +import { addAlbumToPlaylist, startPlayback } from "../../api"; interface AlbumListProps { albumList: Album[]; @@ -16,19 +18,54 @@ export const AlbumList: FC = ({ associatedPlaylists, activeAlbumId, }) => { + const activeAlbumIndex = albumList.findIndex((album) => album.id === activeAlbumId); + const [selectedAlbum, setSelectedAlbum] = useState(undefined) + const onAlbumClick = (album: Album) => { + if (selectedAlbum && selectedAlbum.id == album.id) { + setSelectedAlbum(undefined) + } else { + setSelectedAlbum(album) + } + } + return ( - -
- {albumList.map((album) => ( +
+ ( + ) + )}/> + {selectedAlbum && +
+
+
album: {selectedAlbum.name}
+
+ artists: {selectedAlbum.artists.map((artist) => artist.name).join(", ")} +
+
genres: {selectedAlbum.genres}
+
label: {selectedAlbum.label}
+
+
+ {associatedPlaylists.map((associatedPlaylist) => ( + ))} -
- + +
+
} +
); + }; diff --git a/frontend/src/playlistExplorer/PlaylistExplorer.tsx b/frontend/src/playlistExplorer/PlaylistExplorer.tsx index 7405794..6659970 100644 --- a/frontend/src/playlistExplorer/PlaylistExplorer.tsx +++ b/frontend/src/playlistExplorer/PlaylistExplorer.tsx @@ -6,9 +6,9 @@ import InputLabel from "../components/InputLabel"; import Button from "../components/Button"; import { Form, useForm } from "react-hook-form"; import { - findAssociatedPlaylists, getPlaylistAlbums, getPlaylistTracks, + playlistSearch, updatePlaylist, } from "../api"; import { useQuery } from "@tanstack/react-query"; @@ -53,9 +53,9 @@ export const PlaylistExplorer: FC = () => { useEffect(() => { if (playlist.name.slice(0, 10) === "New Albums") { - findAssociatedPlaylists(playlist).then( + playlistSearch(playlist.name.slice(11)).then( (associatedPlaylists: Playlist[]) => { - setAssociatedPlaylists(associatedPlaylists); + setAssociatedPlaylists(associatedPlaylists.filter((associatedPlaylist) => associatedPlaylist.name !== playlist.name)); } ); } From 815cf686d76890c6f8b8894a4525d06e71934646 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Sat, 14 Sep 2024 08:39:15 +0100 Subject: [PATCH 16/24] Remove psycopg --- backend/poetry.lock | 36 +----------------------------------- backend/pyproject.toml | 1 - 2 files changed, 1 insertion(+), 36 deletions(-) diff --git a/backend/poetry.lock b/backend/poetry.lock index 2bbc00b..5402447 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -396,29 +396,6 @@ 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" @@ -643,17 +620,6 @@ 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" @@ -691,4 +657,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "8c0f2ef1be23afbe42bdcf1de578a9fbe6ed05886d9f7811d0dead81b9aa8760" +content-hash = "38c06b14a3b5eac97e022c6efe4f9dc4611dc8a7c862a1c97d30121acb89ce27" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9038c55..2b50b0e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,7 +18,6 @@ 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" From d8f3522c8322855dfc48e884f50d814e35d95a93 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Sat, 14 Sep 2024 08:45:09 +0100 Subject: [PATCH 17/24] linting fixes --- frontend/eslint.config.js | 3 --- frontend/package-lock.json | 2 +- frontend/package.json | 2 +- frontend/src/MainPage.tsx | 1 - frontend/src/components/LoadingSpinner.tsx | 2 +- frontend/src/presentational/Header/Header.tsx | 2 +- frontend/src/settingsPage/PopulateUserDatabaseButton.tsx | 2 +- 7 files changed, 5 insertions(+), 9 deletions(-) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e43726..b9cd068 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -10,9 +10,6 @@ export default [ pluginJs.configs.recommended, ...tseslint.configs.recommended, pluginReact.configs.flat.recommended, - { - rules: {"unused-imports/no-unused-imports": "error"} - }, { ignores: ["public/bundle.js"], }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c06b897..f0babeb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@tanstack/react-table": "^8.20.1", "@tanstack/react-query": "^5.51.21", + "@tanstack/react-table": "^8.20.1", "dotenv": "^16.4.5", "moment": "^2.30.1", "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 6de74ee..52affb2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,8 +41,8 @@ "typescript-eslint": "^8.2.0" }, "dependencies": { - "@tanstack/react-table": "^8.20.1", "@tanstack/react-query": "^5.51.21", + "@tanstack/react-table": "^8.20.1", "dotenv": "^16.4.5", "moment": "^2.30.1", "react": "^18.3.1", diff --git a/frontend/src/MainPage.tsx b/frontend/src/MainPage.tsx index 927cb7b..31bdd19 100644 --- a/frontend/src/MainPage.tsx +++ b/frontend/src/MainPage.tsx @@ -7,7 +7,6 @@ import Box from "./components/Box"; import AddPlaylistForm from "./AddPlaylistForm"; import { GoArrowLeft, GoArrowRight } from "react-icons/go"; import CustomButton from "./components/Button"; -import PlaybackFooter from "./presentational/PlaybackFooter"; import useWindowSize from "./hooks/useWindowSize"; interface PaginationState { diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx index 822fe9f..ac5b1b5 100644 --- a/frontend/src/components/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner.tsx @@ -1,6 +1,6 @@ import React from "react" -const LoadingSpinner = ({}) => { +const LoadingSpinner = () => { return (
{props.children} diff --git a/frontend/src/components/Carousel/Carousel.tsx b/frontend/src/components/Carousel/Carousel.tsx index 9e339b2..37b91d5 100644 --- a/frontend/src/components/Carousel/Carousel.tsx +++ b/frontend/src/components/Carousel/Carousel.tsx @@ -1,4 +1,4 @@ -import React, { FC, ReactNode } from 'react' +import React, { FC, ReactNode, useEffect } from 'react' import useEmblaCarousel from 'embla-carousel-react' interface SlideProps { @@ -13,10 +13,17 @@ const Slide: FC = ({children}) => { interface CarouselProps { slides: ReactNode[] startIndex?: number + selectedIndex?: number } -const Carousel: FC = ({slides, startIndex = 0}) => { - const [emblaRef] = useEmblaCarousel({skipSnaps: true, startIndex}) +const Carousel: FC = ({slides, startIndex = 0, selectedIndex}) => { + const [emblaRef, emblaApi] = useEmblaCarousel({skipSnaps: true, startIndex}) + + useEffect(() => { + if (emblaApi && selectedIndex) { + emblaApi.scrollTo(selectedIndex) + } + }, [selectedIndex, emblaApi]) return (
diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Inputs/Input.tsx similarity index 100% rename from frontend/src/components/Input.tsx rename to frontend/src/components/Inputs/Input.tsx diff --git a/frontend/src/components/InputLabel.tsx b/frontend/src/components/Inputs/InputLabel.tsx similarity index 100% rename from frontend/src/components/InputLabel.tsx rename to frontend/src/components/Inputs/InputLabel.tsx diff --git a/frontend/src/components/Inputs/InputWithLabelPlaceholder.tsx b/frontend/src/components/Inputs/InputWithLabelPlaceholder.tsx new file mode 100644 index 0000000..9333c22 --- /dev/null +++ b/frontend/src/components/Inputs/InputWithLabelPlaceholder.tsx @@ -0,0 +1,36 @@ +import React, { FC } from "react"; +import { UseFormRegisterReturn } from "react-hook-form"; + +interface CustomInputProps { + name: string; + register: UseFormRegisterReturn; + defaultValue?: string; + placeholder: string; + type: React.HTMLInputTypeAttribute; +} + +const InputWithLabelPlaceholder: FC = ({ + name, + register, + defaultValue, + placeholder, + type, +}) => ( +
+ + +
+); + +export default InputWithLabelPlaceholder; diff --git a/frontend/src/playlistExplorer/AlbumList/AlbumActions.tsx b/frontend/src/playlistExplorer/AlbumList/AlbumActions.tsx new file mode 100644 index 0000000..acab3ab --- /dev/null +++ b/frontend/src/playlistExplorer/AlbumList/AlbumActions.tsx @@ -0,0 +1,33 @@ +import React, { FC } from "react"; +import { Album } from "../../interfaces/Album"; +import Button from "../../components/Button"; +import { Playlist } from "../../interfaces/Playlist"; +import { addAlbumToPlaylist, startPlayback } from "../../api"; + +interface AlbumActionsProps { + album: Album + associatedPlaylists: Playlist[] + contextPlaylist: Playlist +} + +const AlbumActions: FC = ({album, associatedPlaylists, contextPlaylist}) => { + return ( +
+ {associatedPlaylists.map((associatedPlaylist) => ( + + ))} + +
+ ) +} + +export default AlbumActions diff --git a/frontend/src/playlistExplorer/AlbumList/AlbumContainer.tsx b/frontend/src/playlistExplorer/AlbumList/AlbumContainer.tsx index 577c8cd..14120ed 100644 --- a/frontend/src/playlistExplorer/AlbumList/AlbumContainer.tsx +++ b/frontend/src/playlistExplorer/AlbumList/AlbumContainer.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState } from "react"; +import React, { FC } from "react"; import { Album } from "../../interfaces/Album"; import PlaylistIcon from "../../components/PlaylistIcon"; import { RotatingBorderBox } from "../../components/RotatingBorderBox"; @@ -6,31 +6,31 @@ import { RotatingBorderBox } from "../../components/RotatingBorderBox"; interface AlbumContainerProps { album: Album; onClick: (album: Album) => void; + selected: boolean; active?: boolean; } export const AlbumContainer: FC = ({ album, onClick, + selected, active, }) => { - const [showMoreInfo, setShowMoreInfo] = useState(false); return (
{ - setShowMoreInfo((current) => !current); onClick(album) }} >
- - {showMoreInfo && ( + + {selected && (
{album.name}
diff --git a/frontend/src/playlistExplorer/AlbumList/AlbumInfo.tsx b/frontend/src/playlistExplorer/AlbumList/AlbumInfo.tsx new file mode 100644 index 0000000..b1fbf08 --- /dev/null +++ b/frontend/src/playlistExplorer/AlbumList/AlbumInfo.tsx @@ -0,0 +1,21 @@ +import React, { FC } from "react"; +import { Album } from "../../interfaces/Album"; + +interface AlbumInfoProps { + album: Album +} + +const AlbumInfo: FC = ({album}) => { + return ( +
+
album: {album.name}
+
+ artists: {album.artists.map((artist) => artist.name).join(", ")} +
+
genres: {album.genres}
+
label: {album.label}
+
+ ) +} + +export default AlbumInfo \ No newline at end of file diff --git a/frontend/src/playlistExplorer/AlbumList/AlbumList.tsx b/frontend/src/playlistExplorer/AlbumList/AlbumList.tsx index 19c7ed4..5db5773 100644 --- a/frontend/src/playlistExplorer/AlbumList/AlbumList.tsx +++ b/frontend/src/playlistExplorer/AlbumList/AlbumList.tsx @@ -3,8 +3,9 @@ import { Album } from "../../interfaces/Album"; import { AlbumContainer } from "./AlbumContainer"; import { Playlist } from "../../interfaces/Playlist"; import Carousel from "../../components/Carousel/Carousel"; -import Button from "../../components/Button"; -import { addAlbumToPlaylist, startPlayback } from "../../api"; +import AlbumInfo from "./AlbumInfo"; +import AlbumActions from "./AlbumActions"; +import Box from "../../components/Box"; interface AlbumListProps { albumList: Album[]; @@ -27,44 +28,25 @@ export const AlbumList: FC = ({ setSelectedAlbum(album) } } + const selectedAlbumIndex = selectedAlbum ? albumList.findIndex((album) => album.id === selectedAlbum.id) : undefined; return (
- ( + ( ) )}/> {selectedAlbum && -
-
-
album: {selectedAlbum.name}
-
- artists: {selectedAlbum.artists.map((artist) => artist.name).join(", ")} -
-
genres: {selectedAlbum.genres}
-
label: {selectedAlbum.label}
-
-
- {associatedPlaylists.map((associatedPlaylist) => ( - - ))} - -
-
} + + + + }
); diff --git a/frontend/src/playlistExplorer/PlaylistExplorer.tsx b/frontend/src/playlistExplorer/PlaylistExplorer.tsx index 6659970..ee4577a 100644 --- a/frontend/src/playlistExplorer/PlaylistExplorer.tsx +++ b/frontend/src/playlistExplorer/PlaylistExplorer.tsx @@ -1,8 +1,6 @@ import React, { FC, useEffect, useState } from "react"; import { Playlist } from "../interfaces/Playlist"; import { Link, useLoaderData } from "react-router-dom"; -import Input from "../components/Input"; -import InputLabel from "../components/InputLabel"; import Button from "../components/Button"; import { Form, useForm } from "react-hook-form"; import { @@ -17,6 +15,7 @@ import { AlbumList } from "./AlbumList/AlbumList"; import { TrackList } from "./TrackList/TrackList"; import { usePlaybackContext } from "../hooks/usePlaybackContext"; import { Track } from "../interfaces/Track"; +import InputWithLabelPlaceholder from "../components/Inputs/InputWithLabelPlaceholder"; enum ViewMode { ALBUM = "album", @@ -65,31 +64,26 @@ export const PlaylistExplorer: FC = () => { return (
-
-

Edit Playlist

-
-
+
{ updatePlaylist(getValues()); }} control={control} > -
- Title: - + -
-
- Description: -
@@ -103,9 +97,9 @@ export const PlaylistExplorer: FC = () => {
<> -
+
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 2e61f7b..89f9784 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -80,7 +80,7 @@ const tailwindConfigModule = { lighter: "#3354e6", darker: "#ce4257", }, - text: { primary: "#eae0d5" }, + text: { primary: "#eae0d5", secondary: "#aeacb0" }, }, }, { defaultTheme: { light: "light", dark: "dark" } } From 75778ff3368ff2707312f13cf70749c654fd79d7 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Wed, 16 Oct 2024 09:47:25 +0100 Subject: [PATCH 24/24] Improve playlist explorer and add button/endpoint for updating playlist details in db --- backend/src/controllers/database.py | 26 +++ backend/src/controllers/music_data.py | 16 ++ backend/src/database/crud/playlist.py | 12 ++ backend/src/spotify.py | 4 +- frontend/src/MainPage.tsx | 11 -- frontend/src/api/index.ts | 4 + frontend/src/components/ButtonAsync.tsx | 28 +++ .../src/playlistExplorer/PlaylistExplorer.tsx | 159 +++++++++--------- 8 files changed, 169 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/ButtonAsync.tsx diff --git a/backend/src/controllers/database.py b/backend/src/controllers/database.py index 8e565de..5b98e63 100644 --- a/backend/src/controllers/database.py +++ b/backend/src/controllers/database.py @@ -12,6 +12,7 @@ from src.database.crud.playlist import ( create_playlist, delete_playlist, + get_playlist_albums, get_playlist_by_id_or_none, ) from src.database.crud.user import get_or_create_user @@ -54,6 +55,31 @@ def populate_user(): return make_response("Playlist data populated", 201) + @database_controller.route("populate_playlist/", methods=["GET"]) + def populate_playlist(id): + access_token = request.cookies.get("spotify_access_token") + user = spotify.get_current_user(access_token) + (db_user, _) = get_or_create_user(user) + db_playlist = get_playlist_by_id_or_none(id) + if db_playlist is not None: + delete_playlist(db_playlist.id) + playlist = spotify.get_playlist(access_token=access_token, id=id) + create_playlist(playlist, db_user) + albums = get_playlist_albums(playlist.id) + batch_albums = split_list(albums, 20) + for album_chunk in batch_albums: + albums = spotify.get_multiple_albums( + access_token=access_token, ids=[album.id for album in album_chunk] + ) + for db_album in albums: + with database.database.atomic(): + db_album.genres = musicbrainz.get_album_genres( + db_album.artists[0].name, db_album.name + ) + update_album(db_album) + + return make_response("Playlist details populated", 201) + @database_controller.route("populate_additional_album_details", methods=["GET"]) def populate_additional_album_details(): access_token = request.cookies.get("spotify_access_token") diff --git a/backend/src/controllers/music_data.py b/backend/src/controllers/music_data.py index 878fb96..2eba1d5 100644 --- a/backend/src/controllers/music_data.py +++ b/backend/src/controllers/music_data.py @@ -1,5 +1,6 @@ from flask import Blueprint, jsonify, make_response, request from src.database.crud.playlist import ( + create_playlist_album_relationship, get_playlist_albums_with_genres, get_playlist_by_id_or_none, get_playlist_duration, @@ -112,6 +113,21 @@ def find_associated_playlists(): search = request.json return search_playlist_names(user_id, search) + @music_controller.route("add_album_to_playlist", methods=["POST"]) + def add_album_to_playlist(): + access_token = request.cookies.get("spotify_access_token") + request_body = request.json + playlist_id = request_body["playlistId"] + album_id = request_body["albumId"] + if not playlist_id or not album_id: + return make_response( + "Invalid request payload. Expected playlistId and albumId.", 400 + ) + create_playlist_album_relationship(playlist_id=playlist_id, album_id=album_id) + return spotify.add_album_to_playlist( + access_token=access_token, playlist_id=playlist_id, album_id=album_id + ) + @music_controller.route("playback", methods=["GET"]) def get_playback_info(): access_token = request.cookies.get("spotify_access_token") diff --git a/backend/src/database/crud/playlist.py b/backend/src/database/crud/playlist.py index 9af35e0..55569ae 100644 --- a/backend/src/database/crud/playlist.py +++ b/backend/src/database/crud/playlist.py @@ -326,3 +326,15 @@ def search_playlist_names(user_id: str, search: str) -> List[dict]: ] return result + + +def create_playlist_album_relationship(playlist_id: str, album_id: str): + max_album_index = ( + PlaylistAlbumRelationship.select(fn.MAX(PlaylistAlbumRelationship.album_index)) + .where(PlaylistAlbumRelationship.playlist == playlist_id) + .scalar() + ) + result = PlaylistAlbumRelationship.create( + playlist=playlist_id, album=album_id, album_index=max_album_index + 1 + ) + return diff --git a/backend/src/spotify.py b/backend/src/spotify.py index 298df8d..4c418d4 100644 --- a/backend/src/spotify.py +++ b/backend/src/spotify.py @@ -466,8 +466,8 @@ def add_album_to_playlist(self, access_token, playlist_id, album_id) -> Response auth=BearerAuth(access_token), ) self.response_handler(response, jsonify=False) - if response.status_code == 200: - return make_response("Album successfully added to playlist", 200) + if response.status_code == 201: + return make_response("Album successfully added to playlist", 201) else: return make_response("Failed to add album to playlist", 400) diff --git a/frontend/src/MainPage.tsx b/frontend/src/MainPage.tsx index ccb6fb5..d106dd1 100644 --- a/frontend/src/MainPage.tsx +++ b/frontend/src/MainPage.tsx @@ -31,23 +31,12 @@ export const Index: FC = () => { }, }); - const allQuery = useQuery({ - queryKey: ["playlists", pagination, search], - queryFn: () => { - return getPlaylists(search, pagination.pageIndex, pagination.pageSize); - }, - }); - return (
- - - - diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 8075db9..7ffd92c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -162,6 +162,10 @@ export const populateAdditionalAlbumDetails = async (): Promise => { return request('database/populate_additional_album_details', RequestMethod.GET) } +export const populatePlaylist = async (id: string): Promise => { + return request(`database/populate_playlist/${id}`, RequestMethod.GET) +} + export const populateUniversalGenreList = async (): Promise => { return request('database/populate_universal_genre_list', RequestMethod.GET) } diff --git a/frontend/src/components/ButtonAsync.tsx b/frontend/src/components/ButtonAsync.tsx new file mode 100644 index 0000000..15c9118 --- /dev/null +++ b/frontend/src/components/ButtonAsync.tsx @@ -0,0 +1,28 @@ +import React, { FC, useState, MouseEvent } from "react"; + +const ButtonAsync: FC< + React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement + > +> = ({className, onClick, ...props}) => { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async (event: MouseEvent): Promise => { + if (onClick) { + setIsLoading(true) + await onClick(event) + setIsLoading(false); + } + } + return ( + +
+ Back
-
- -
- Back -
-
- - <> -
-
+ + populatePlaylist(playlist.id)}> + Sync new playlist data + + <> +
+ -
-
- {viewMode == ViewMode.ALBUM && playlistAlbums && ( - - )} - {viewMode == ViewMode.TRACK && playlistTracks &&( - - )} -
- -
+ Album View + +

+ Track View +

+ +
+
+ {viewMode == ViewMode.ALBUM && playlistAlbums && ( + + )} + {viewMode == ViewMode.TRACK && playlistTracks &&( + + )} +
+
); };