From 8f821570c5cb0df76616f08bb2108a1147678433 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Wed, 24 Jul 2024 09:53:06 +0100 Subject: [PATCH 1/7] Add pause/play functionality to the playback footer --- backend/src/controllers/spotify.py | 13 ++++++ backend/src/dataclasses/playback_info.py | 1 + backend/src/dataclasses/playback_request.py | 19 ++++++++ backend/src/spotify.py | 46 +++++++++++++++++-- compose.yaml | 4 +- frontend/src/api/index.ts | 18 ++++++++ frontend/src/api/jsonRequest.ts | 2 + frontend/src/context/PlaybackContext.tsx | 7 +-- frontend/src/interfaces/PlaybackInfo.ts | 1 + .../src/presentational/PlaybackFooter.tsx | 18 ++++++-- 10 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 backend/src/dataclasses/playback_request.py diff --git a/backend/src/controllers/spotify.py b/backend/src/controllers/spotify.py index 6cee3ac..6a63c74 100644 --- a/backend/src/controllers/spotify.py +++ b/backend/src/controllers/spotify.py @@ -1,5 +1,6 @@ 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 @@ -125,4 +126,16 @@ def add_album_to_playlist(): access_token=access_token, playlist_id=playlist_id, album_id=album_id ) + @spotify_controller.route("pause_playback", methods=["PUT"]) + def pause_playback(): + access_token = request.cookies.get("spotify_access_token") + return spotify.pause_playback(access_token) + + @spotify_controller.route("start_playback", methods=["PUT"]) + def start_playback(): + access_token = request.cookies.get("spotify_access_token") + request_body = request.json + start_playback_request_body = StartPlaybackRequest.model_validate(request_body) + return spotify.start_playback(access_token, start_playback_request_body) + return spotify_controller diff --git a/backend/src/dataclasses/playback_info.py b/backend/src/dataclasses/playback_info.py index 92563ef..39cd0f4 100644 --- a/backend/src/dataclasses/playback_info.py +++ b/backend/src/dataclasses/playback_info.py @@ -23,6 +23,7 @@ class PlaybackInfo(BaseModel): track_duration: float album_progress: float album_duration: float + is_playing: bool def get_formatted_artists(self) -> str: return ", ".join(self.track_artists) diff --git a/backend/src/dataclasses/playback_request.py b/backend/src/dataclasses/playback_request.py new file mode 100644 index 0000000..467ad6a --- /dev/null +++ b/backend/src/dataclasses/playback_request.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from typing import List, Optional + + +class StartPlaybackRequestPositionOffset(BaseModel): + position: int + + +class StartPlaybackRequestUriOffset(BaseModel): + uri: str + + +class StartPlaybackRequest(BaseModel): + context_uri: Optional[str] = None + uris: Optional[List[str]] = None + offset: Optional[ + StartPlaybackRequestPositionOffset | StartPlaybackRequestUriOffset + ] = None + position_ms: Optional[int] = None diff --git a/backend/src/spotify.py b/backend/src/spotify.py index f81b8ad..bdf9e0e 100644 --- a/backend/src/spotify.py +++ b/backend/src/spotify.py @@ -5,6 +5,7 @@ from flask import Response, make_response, redirect from src.dataclasses.album import Album from src.dataclasses.playback_info import PlaybackInfo, PlaylistProgression +from src.dataclasses.playback_request import StartPlaybackRequest from src.dataclasses.playback_state import PlaybackState from src.dataclasses.playlist import Playlist from src.dataclasses.playlist_info import CurrentUserPlaylists, SimplifiedPlaylist @@ -28,6 +29,7 @@ "playlist-read-collaborative", "playlist-modify-private", "playlist-modify-public", + "user-modify-playback-state", ] @@ -293,7 +295,7 @@ def get_album(self, access_token, id): album = Album.model_validate(api_album) return album - def get_current_playback(self, access_token): + def get_current_playback(self, access_token) -> PlaybackState | None: response = requests.get( f"https://api.spotify.com/v1/me/player", auth=BearerAuth(access_token), @@ -345,6 +347,7 @@ def get_my_current_playback(self, access_token) -> PlaybackInfo | None: "track_duration": api_playback.item.duration_ms, "album_progress": album_progress, "album_duration": album_duration, + "is_playing": api_playback.is_playing, } ) @@ -366,11 +369,13 @@ def get_playlist_progression(self, access_token, api_playback: PlaybackInfo): } ) - def search_albums(self, access_token, search=None, offset=0) -> List[Album]: + def search_albums( + self, access_token, search=None, offset=0, limit=50 + ) -> List[Album]: if search: response = requests.get( f"https://api.spotify.com/v1/albums/{id}", - data={"q": search, "type": "album", "limit": 50, "offset": offset}, + data={"q": search, "type": "album", "limit": limit, "offset": offset}, headers={ "content-type": "application/json", }, @@ -456,6 +461,41 @@ def is_album_in_playlist(self, access_token, playlist_id, album: Album) -> bool: album_track_ids = [track.id for track in album.tracks.items] return all(e in playlist_track_ids for e in album_track_ids) + def pause_playback(self, access_token) -> Response: + response = requests.put( + url="https://api.spotify.com/v1/me/player/pause", + headers={ + "content-type": "application/json", + }, + auth=BearerAuth(access_token), + ) + foo = self.response_handler( + make_response("", response.status_code), jsonify=False + ) + return foo + + def start_playback( + self, access_token, start_playback_request_body: StartPlaybackRequest = None + ) -> Response: + request_json = start_playback_request_body.model_dump_json(exclude_none=True) + if request_json == {}: + data = None + else: + data = request_json + + response = requests.put( + url="https://api.spotify.com/v1/me/player/play", + data=data, + headers={ + "content-type": "application/json", + }, + auth=BearerAuth(access_token), + ) + foo = self.response_handler( + make_response("", response.status_code), jsonify=False + ) + return foo + def get_playlist_duration(playlist_info: List[PlaylistTrackObject]) -> int: return sum(track.track.duration_ms for track in playlist_info) diff --git a/compose.yaml b/compose.yaml index 012e8f8..1a7c246 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,7 +4,7 @@ services: build: context: frontend dockerfile: Dockerfile - target: production + target: development ports: - "8080:8080" env_file: @@ -21,7 +21,7 @@ services: build: context: backend dockerfile: Dockerfile - target: production + target: development ports: - "5000:5000" env_file: diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1b1cc90..355e15e 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -104,3 +104,21 @@ export const addAlbumToPlaylist = async ( albumId, }); }; + +export const pausePlayback = async (): Promise => { + return jsonRequest(`spotify/pause_playback`, RequestMethod.PUT); +}; + +export const startPlayback = async ( + context_uri?: string, + uris?: string[], + offset?: {position: number} | {uri: string}, + position_ms?: number +): Promise => { + return jsonRequest(`spotify/start_playback`, RequestMethod.PUT, { + context_uri, + uris, + offset, + position_ms + }); +}; \ No newline at end of file diff --git a/frontend/src/api/jsonRequest.ts b/frontend/src/api/jsonRequest.ts index 69e93c5..9bf56ca 100644 --- a/frontend/src/api/jsonRequest.ts +++ b/frontend/src/api/jsonRequest.ts @@ -5,6 +5,7 @@ export const backendUrl = process.env.BACKEND_URL; export enum RequestMethod { GET = "get", POST = "post", + PUT = "put", } export const jsonRequest = async ( @@ -16,6 +17,7 @@ export const jsonRequest = async ( let fetchOptions: RequestInit = { credentials: "include" }; switch (method) { case RequestMethod.POST: + case RequestMethod.PUT: fetchOptions = { ...fetchOptions, method: method, diff --git a/frontend/src/context/PlaybackContext.tsx b/frontend/src/context/PlaybackContext.tsx index 18905a3..4097cf5 100644 --- a/frontend/src/context/PlaybackContext.tsx +++ b/frontend/src/context/PlaybackContext.tsx @@ -6,11 +6,12 @@ import React, { useState, } from "react"; import { PlaybackInfo, PlaylistProgress } from "../interfaces/PlaybackInfo"; -import { useQuery } from "@tanstack/react-query"; +import { QueryObserverResult, RefetchOptions, useQuery } from "@tanstack/react-query"; import { getPlaybackInfo, getPlaylistProgress } from "../api"; interface PlaybackContext { playbackInfo?: PlaybackInfo; + refetchPlaybackInfo?: (options?: RefetchOptions) => Promise> playlistProgress?: PlaylistProgress; } @@ -24,7 +25,7 @@ export const PlaybackContextProvider: FC = ({ children, }) => { const [playbackRefetchInterval, setPlaybackRefetchInterval] = useState(5000); - const { data: playbackInfo } = useQuery({ + const { data: playbackInfo, refetch: refetchPlaybackInfo } = useQuery({ queryKey: ["playbackInfo"], queryFn: () => { return getPlaybackInfo(); @@ -50,7 +51,7 @@ export const PlaybackContextProvider: FC = ({ }); return ( - + {children} ); diff --git a/frontend/src/interfaces/PlaybackInfo.ts b/frontend/src/interfaces/PlaybackInfo.ts index 4a0d1c9..21cce30 100644 --- a/frontend/src/interfaces/PlaybackInfo.ts +++ b/frontend/src/interfaces/PlaybackInfo.ts @@ -11,6 +11,7 @@ export interface PlaybackInfo { track_duration: number; album_progress: number; album_duration: number; + is_playing: boolean; } export interface PlaylistProgress { diff --git a/frontend/src/presentational/PlaybackFooter.tsx b/frontend/src/presentational/PlaybackFooter.tsx index 32466f8..8cae3e7 100644 --- a/frontend/src/presentational/PlaybackFooter.tsx +++ b/frontend/src/presentational/PlaybackFooter.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import React, { FC, useEffect, useState } from "react"; -import { getPlaybackInfo, getPlaylistProgress } from "../api"; +import { getPlaybackInfo, getPlaylistProgress, pausePlayback, startPlayback } from "../api"; import { PlaybackInfo, PlaylistProgress } from "../interfaces/PlaybackInfo"; import { ProgressCircle } from "../components/ProgressCircle"; import useWindowSize from "../hooks/useWindowSize"; @@ -12,14 +12,26 @@ import { Link } from "react-router-dom"; const PlaybackFooter: FC = () => { const { isMobileView } = useWindowSize(); - const { playbackInfo, playlistProgress } = usePlaybackContext(); + const { playbackInfo, playlistProgress, refetchPlaybackInfo } = usePlaybackContext(); if (!playbackInfo) return null; + + const handlePausePlayClick = (): void => { + if (playbackInfo.is_playing) { + pausePlayback() + } else { + startPlayback() + } + refetchPlaybackInfo?.() + } + return (
- +
Playing:
{playbackInfo.album_artists.join(", ")} From 38c1ceb26a9e49a69c1b08524f7f0f6e974ef5da Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Wed, 24 Jul 2024 17:01:46 +0100 Subject: [PATCH 2/7] Handle pause-play decision on backend to stop requests erroring on quick successive clicks --- backend/src/controllers/spotify.py | 7 ++++++- backend/src/spotify.py | 18 +++++++++++------- frontend/src/api/index.ts | 4 ++++ frontend/src/context/PlaybackContext.tsx | 7 +++---- frontend/src/presentational/PlaybackFooter.tsx | 11 +++-------- 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/backend/src/controllers/spotify.py b/backend/src/controllers/spotify.py index 6a63c74..d13cfdc 100644 --- a/backend/src/controllers/spotify.py +++ b/backend/src/controllers/spotify.py @@ -135,7 +135,12 @@ def pause_playback(): def start_playback(): access_token = request.cookies.get("spotify_access_token") request_body = request.json - start_playback_request_body = StartPlaybackRequest.model_validate(request_body) + start_playback_request_body = StartPlaybackRequest.model_validate(request_body) if request_body else None return spotify.start_playback(access_token, start_playback_request_body) + @spotify_controller.route("pause_or_start_playback", methods=["PUT"]) + def pause_or_start_playback(): + access_token = request.cookies.get("spotify_access_token") + return spotify.pause_or_start_playback(access_token) + return spotify_controller diff --git a/backend/src/spotify.py b/backend/src/spotify.py index bdf9e0e..fcd91aa 100644 --- a/backend/src/spotify.py +++ b/backend/src/spotify.py @@ -469,19 +469,17 @@ def pause_playback(self, access_token) -> Response: }, auth=BearerAuth(access_token), ) - foo = self.response_handler( + return self.response_handler( make_response("", response.status_code), jsonify=False ) - return foo def start_playback( self, access_token, start_playback_request_body: StartPlaybackRequest = None ) -> Response: - request_json = start_playback_request_body.model_dump_json(exclude_none=True) - if request_json == {}: + if not start_playback_request_body: data = None else: - data = request_json + data = start_playback_request_body.model_dump_json(exclude_none=True) response = requests.put( url="https://api.spotify.com/v1/me/player/play", @@ -491,10 +489,16 @@ def start_playback( }, auth=BearerAuth(access_token), ) - foo = self.response_handler( + return self.response_handler( make_response("", response.status_code), jsonify=False ) - return foo + + def pause_or_start_playback(self, access_token) -> Response: + is_playing = self.get_current_playback(access_token).is_playing + if is_playing: + return self.pause_playback(access_token) + else: + return self.start_playback(access_token) def get_playlist_duration(playlist_info: List[PlaylistTrackObject]) -> int: diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 355e15e..332be56 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -121,4 +121,8 @@ export const startPlayback = async ( offset, position_ms }); +}; + +export const pauseOrStartPlayback = async (): Promise => { + return jsonRequest(`spotify/pause_or_start_playback`, RequestMethod.PUT); }; \ No newline at end of file diff --git a/frontend/src/context/PlaybackContext.tsx b/frontend/src/context/PlaybackContext.tsx index 4097cf5..18905a3 100644 --- a/frontend/src/context/PlaybackContext.tsx +++ b/frontend/src/context/PlaybackContext.tsx @@ -6,12 +6,11 @@ import React, { useState, } from "react"; import { PlaybackInfo, PlaylistProgress } from "../interfaces/PlaybackInfo"; -import { QueryObserverResult, RefetchOptions, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { getPlaybackInfo, getPlaylistProgress } from "../api"; interface PlaybackContext { playbackInfo?: PlaybackInfo; - refetchPlaybackInfo?: (options?: RefetchOptions) => Promise> playlistProgress?: PlaylistProgress; } @@ -25,7 +24,7 @@ export const PlaybackContextProvider: FC = ({ children, }) => { const [playbackRefetchInterval, setPlaybackRefetchInterval] = useState(5000); - const { data: playbackInfo, refetch: refetchPlaybackInfo } = useQuery({ + const { data: playbackInfo } = useQuery({ queryKey: ["playbackInfo"], queryFn: () => { return getPlaybackInfo(); @@ -51,7 +50,7 @@ export const PlaybackContextProvider: FC = ({ }); return ( - + {children} ); diff --git a/frontend/src/presentational/PlaybackFooter.tsx b/frontend/src/presentational/PlaybackFooter.tsx index 8cae3e7..4b18bf5 100644 --- a/frontend/src/presentational/PlaybackFooter.tsx +++ b/frontend/src/presentational/PlaybackFooter.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import React, { FC, useEffect, useState } from "react"; -import { getPlaybackInfo, getPlaylistProgress, pausePlayback, startPlayback } from "../api"; +import { getPlaybackInfo, getPlaylistProgress, pauseOrStartPlayback, pausePlayback, startPlayback } from "../api"; import { PlaybackInfo, PlaylistProgress } from "../interfaces/PlaybackInfo"; import { ProgressCircle } from "../components/ProgressCircle"; import useWindowSize from "../hooks/useWindowSize"; @@ -12,17 +12,12 @@ import { Link } from "react-router-dom"; const PlaybackFooter: FC = () => { const { isMobileView } = useWindowSize(); - const { playbackInfo, playlistProgress, refetchPlaybackInfo } = usePlaybackContext(); + const { playbackInfo, playlistProgress } = usePlaybackContext(); if (!playbackInfo) return null; const handlePausePlayClick = (): void => { - if (playbackInfo.is_playing) { - pausePlayback() - } else { - startPlayback() - } - refetchPlaybackInfo?.() + pauseOrStartPlayback() } return ( From 9e2f44d9381f6c158153704e5871744b3121e46a Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Thu, 25 Jul 2024 08:03:25 +0100 Subject: [PATCH 3/7] Animate song icon when music playing --- frontend/src/presentational/PlaybackFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/presentational/PlaybackFooter.tsx b/frontend/src/presentational/PlaybackFooter.tsx index 4b18bf5..14b5366 100644 --- a/frontend/src/presentational/PlaybackFooter.tsx +++ b/frontend/src/presentational/PlaybackFooter.tsx @@ -35,7 +35,7 @@ const PlaybackFooter: FC = () => {
- +
{playbackInfo.track_title}
From 55bd98c3e96803261d2b21baab18fac0924f8924 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Thu, 25 Jul 2024 08:21:59 +0100 Subject: [PATCH 4/7] Abandon response factory pattern since make_response needs flask app context and we're not doing anything complex enough to warrant passing it --- backend/src/app.py | 1 + backend/src/controllers/spotify.py | 4 +++- backend/src/spotify.py | 28 +++++++++++---------------- backend/src/utils/response_creator.py | 17 +++++----------- 4 files changed, 20 insertions(+), 30 deletions(-) diff --git a/backend/src/app.py b/backend/src/app.py index 00c5da0..1fcced1 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -20,6 +20,7 @@ def create_app(): SESSION_COOKIE_SAMESITE="None", SESSION_COOKIE_SECURE="True", ) + cors = CORS( app, resources={ diff --git a/backend/src/controllers/spotify.py b/backend/src/controllers/spotify.py index d13cfdc..4b2bcbb 100644 --- a/backend/src/controllers/spotify.py +++ b/backend/src/controllers/spotify.py @@ -135,7 +135,9 @@ def pause_playback(): def start_playback(): access_token = request.cookies.get("spotify_access_token") request_body = request.json - start_playback_request_body = StartPlaybackRequest.model_validate(request_body) if request_body else None + start_playback_request_body = ( + StartPlaybackRequest.model_validate(request_body) if request_body else None + ) return spotify.start_playback(access_token, start_playback_request_body) @spotify_controller.route("pause_or_start_playback", methods=["PUT"]) diff --git a/backend/src/spotify.py b/backend/src/spotify.py index fcd91aa..e30e975 100644 --- a/backend/src/spotify.py +++ b/backend/src/spotify.py @@ -16,7 +16,7 @@ from src.dataclasses.user import User from src.exceptions.Unauthorized import UnauthorizedException from src.flask_config import Config -from src.utils.response_creator import ResponseCreator +from src.utils.response_creator import add_cookies_to_response scope = [ "user-library-read", @@ -95,12 +95,9 @@ def refresh_access_token(self, refresh_token): token_response = TokenResponse.model_validate(api_response) access_token = token_response.access_token user_info = self.get_current_user(access_token) - resp = ( - ResponseCreator() - .with_cookies( - {"spotify_access_token": access_token, "user_id": user_info.id} - ) - .create() + resp = add_cookies_to_response( + make_response(), + {"spotify_access_token": access_token, "user_id": user_info.id}, ) return resp @@ -121,16 +118,13 @@ def request_access_token(self, code): token_response = TokenResponse.model_validate(api_response) access_token = token_response.access_token user_info = self.get_current_user(access_token) - resp = ( - ResponseCreator(redirect(f"{Config().FRONTEND_URL}/")) - .with_cookies( - { - "spotify_access_token": access_token, - "spotify_refresh_token": token_response.refresh_token, - "user_id": user_info.id, - } - ) - .create() + resp = add_cookies_to_response( + make_response(redirect(f"{Config().FRONTEND_URL}/")), + { + "spotify_access_token": access_token, + "spotify_refresh_token": token_response.refresh_token, + "user_id": user_info.id, + }, ) return resp diff --git a/backend/src/utils/response_creator.py b/backend/src/utils/response_creator.py index 842dffc..fba32e3 100644 --- a/backend/src/utils/response_creator.py +++ b/backend/src/utils/response_creator.py @@ -1,14 +1,7 @@ -from flask import Response, make_response +from flask import Response -class ResponseCreator: - def __init__(self): - self.response = make_response() - - def with_cookies(self, cookie_dict: dict): - for key, value in cookie_dict.items(): - self.response.set_cookie(key, value, samesite="None", secure=True) - return self - - def create(self) -> Response: - return self.response +def add_cookies_to_response(response: Response, cookie_dict: dict): + for key, value in cookie_dict.items(): + response.set_cookie(key, value, samesite="None", secure=True) + return response From d7e0999839a5337629eba050c9d70bf767da0f25 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Thu, 25 Jul 2024 08:23:11 +0100 Subject: [PATCH 5/7] Formatting --- frontend/src/api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 332be56..ab53d0b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -125,4 +125,4 @@ export const startPlayback = async ( export const pauseOrStartPlayback = async (): Promise => { return jsonRequest(`spotify/pause_or_start_playback`, RequestMethod.PUT); -}; \ No newline at end of file +}; From 0997a83747d2185ff3d4e107a78c81e7de4da335 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Thu, 25 Jul 2024 08:26:12 +0100 Subject: [PATCH 6/7] Update tests --- backend/src/tests/mock_builders/playback_info_builder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/tests/mock_builders/playback_info_builder.py b/backend/src/tests/mock_builders/playback_info_builder.py index 105eded..2a316e2 100644 --- a/backend/src/tests/mock_builders/playback_info_builder.py +++ b/backend/src/tests/mock_builders/playback_info_builder.py @@ -11,6 +11,7 @@ def playback_info_builder( track_duration=180000, album_progress=1000000, album_duration=18000000, + is_playing=True, ): return PlaybackInfo.model_validate( { @@ -26,5 +27,6 @@ def playback_info_builder( "track_duration": track_duration, "album_progress": album_progress, "album_duration": album_duration, + "is_playing": is_playing, } ) From 455bef8104c222ffd862f3ac6e567f62f02a74c7 Mon Sep 17 00:00:00 2001 From: Calum Pinder Date: Thu, 25 Jul 2024 08:28:27 +0100 Subject: [PATCH 7/7] Remove pull request from CI triggers to stop duplicates running --- .github/workflows/ci-pipeline.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index dd94be8..c39d6d2 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -4,10 +4,6 @@ on: paths-ignore: - "diagrams/*" - "**/README.md" - pull_request: - paths-ignore: - - "diagrams/*" - - "**/README.md" jobs: build: