diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index a149c03..419da94 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -27,7 +27,7 @@ // Remove extra spaces in a nullable typehint. 'compact_nullable_typehint' => true, // Concatenation should be spaced according to configuration. - 'concat_space' => ['spacing' => 'none'], + 'concat_space' => ['spacing' => 'one'], // The PHP constants `true`, `false`, and `null` MUST be written using the correct casing. 'constant_case' => ['case' => 'lower'], // The body of each control structure MUST be enclosed within braces. @@ -208,8 +208,6 @@ 'short_scalar_cast' => true, // A PHP file without end tag must always end with a single empty line feed. 'single_blank_line_at_eof' => true, - // There should be exactly one blank line before a namespace declaration. - 'single_blank_line_before_namespace' => true, // There MUST NOT be more than one property or constant declared per statement. 'single_class_element_per_statement' => ['elements' => ['const', 'property']], // There MUST be one use keyword per declaration. diff --git a/src/Abstractions/Album/Tracks.php b/src/Abstractions/Album/Tracks.php index dc3732d..a076b90 100644 --- a/src/Abstractions/Album/Tracks.php +++ b/src/Abstractions/Album/Tracks.php @@ -3,17 +3,11 @@ namespace Tnapf\Spotify\Abstractions\Album; use Tnapf\JsonMapper\Attributes\ObjectArrayType; +use Tnapf\Spotify\Abstractions\Common\Pages; use Tnapf\Spotify\Abstractions\Track\SimplifiedTrack; -class Tracks +class Tracks extends Pages { - public string $href; - public int $limit; - public ?string $next; - public int $offset; - public ?string $previous; - public int $total; - /** @var SimplifiedTrack[] */ #[ObjectArrayType(name: 'items', class: SimplifiedTrack::class)] public array $items; diff --git a/src/Abstractions/Artist/Artists.php b/src/Abstractions/Artist/Artists.php new file mode 100644 index 0000000..e76b82e --- /dev/null +++ b/src/Abstractions/Artist/Artists.php @@ -0,0 +1,13 @@ +$name; + if ($name === 'http') { + return $this->http; } $rest = [ @@ -35,6 +39,9 @@ public function __get(string $name): mixed 'tracks' => Tracks::class, 'artists' => Artists::class, 'playlists' => Playlists::class, + 'audiobooks' => Audiobooks::class, + 'users' => Users::class, + 'player' => Player::class ]; if (!isset($rest[$name])) { diff --git a/src/Http.php b/src/Http.php index 81cc56e..4c9418a 100644 --- a/src/Http.php +++ b/src/Http.php @@ -11,11 +11,13 @@ use Throwable; use Tnapf\JsonMapper\MapperException; use Tnapf\Spotify\Abstractions\Authorization\AccessToken; +use Tnapf\Spotify\Abstractions\Authorization\Scope; use Tnapf\Spotify\Abstractions\Errors\AuthenticationError; use Tnapf\Spotify\Abstractions\Errors\Error; use Tnapf\Spotify\Enums\Method; use Tnapf\Spotify\Exceptions\HttpException; use Tnapf\JsonMapper\MapperInterface; +use ValueError; class Http { @@ -32,7 +34,13 @@ public function __construct( protected readonly string $id, protected readonly string $secret ) { - $this->token = $this->getAuthenticationToken(); + $this->withAccessToken($this->getAuthenticationToken()); + } + + public function withAccessToken(AccessToken $token): self + { + $this->token = $token; + return $this; } public function getAuthenticationToken(): AccessToken @@ -41,10 +49,65 @@ public function getAuthenticationToken(): AccessToken AccessToken::class, Method::POST, 'https://accounts.spotify.com/api/token', - 'grant_type=client_credentials', + "grant_type=client_credentials&client_id={$this->id}&client_secret={$this->secret}", + [ + 'content-type' => 'application/x-www-form-urlencoded', + 'authorization' => 'Basic ' . base64_encode("{$this->id}:{$this->secret}"), + ] + ); + } + + public function requestUserAuthorization(string $redirectUri, array $scopes = []): string + { + foreach ($scopes as $scope) { + if (!$scope instanceof Scope) { + throw new ValueError('Scopes must be an instance of ' . Scope::class); + } + } + + $scopes = array_map(static fn (Scope $scope) => $scope->value, $scopes); + $base = 'https://accounts.spotify.com/authorize'; + $query = http_build_query([ + 'client_id' => $this->id, + 'response_type' => 'code', + 'redirect_uri' => $redirectUri, + 'scope' => implode(' ', $scopes), + ]); + + return "{$base}?{$query}"; + } + + public function requestUserAccessToken(string $code, string $redirectUri): AccessToken + { + return $this->mapRequest( + AccessToken::class, + Method::POST, + 'https://accounts.spotify.com/api/token', + http_build_query([ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $redirectUri, + ]), [ + 'authorization' => 'Basic ' . base64_encode("{$this->id}:{$this->secret}"), + 'content-type' => 'application/x-www-form-urlencoded', + ] + ); + } + + public function requestRefreshedAccessToken(string $refreshToken): AccessToken + { + return $this->mapRequest( + AccessToken::class, + Method::POST, + 'https://accounts.spotify.com/api/token', + http_build_query([ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ]), + [ + 'authorization' => 'Basic ' . base64_encode("{$this->id}:{$this->secret}"), 'content-type' => 'application/x-www-form-urlencoded', - 'authorization' => 'Basic '.base64_encode("{$this->id}:{$this->secret}"), ] ); } @@ -67,11 +130,15 @@ public function mapRequest(string $class, Method $method, string $uri, ?string $ * @template T * * @param class-string $class - * @param Closure(array): array $callback You can modify the array before the loop to map it + * @param Method $method + * @param string $uri + * @param string|null $body + * @param array $headers + * @param Closure|null $callback You can modify the array before the loop to map it * * @return T[] */ - public function arrayMapRequest(string $class, Method $method, string $uri, ?string $body = null, array $headers = [], ?Closure $callback = null): array + public function mapArrayRequest(string $class, Method $method, string $uri, ?string $body = null, array $headers = [], ?Closure $callback = null): array { $response = $this->request($method, $uri, $body, $headers); $mapped = []; @@ -100,6 +167,10 @@ protected function throwIfNotOkay(ResponseInterface $response): void $json = json_decode($response->getBody()->getContents(), true); + if ($json === null) { + return; + } + if (isset($json['error']['status'])) { $error = $this->mapper->map(Error::class, $json['error']); } else { diff --git a/src/Rest/Albums.php b/src/Rest/Albums.php index 201f724..4a2a18a 100644 --- a/src/Rest/Albums.php +++ b/src/Rest/Albums.php @@ -29,7 +29,7 @@ public function getSeveral(array $ids, ?string $market = null): array { $ids = implode(',', $ids); - return $this->http->arrayMapRequest( + return $this->http->mapArrayRequest( Album::class, Method::GET, Endpoint::bind(Endpoint::ALBUMS, [], compact('ids'), market: $market), diff --git a/src/Rest/Artists.php b/src/Rest/Artists.php index 8bc9953..99e06e9 100644 --- a/src/Rest/Artists.php +++ b/src/Rest/Artists.php @@ -24,7 +24,7 @@ public function getSeveral(array $ids): array { $ids = implode(',', $ids); - return $this->http->arrayMapRequest( + return $this->http->mapArrayRequest( Artist::class, Method::GET, Endpoint::bind(Endpoint::ARTISTS, getParams: compact('ids')), @@ -62,7 +62,7 @@ public function getAlbums(string $id, array $includeGroups = [], ?string $market /** @return Track[] */ public function getTopTracks(string $id, string $market = 'US'): array { - return $this->http->arrayMapRequest( + return $this->http->mapArrayRequest( Track::class, Method::GET, Endpoint::bind(Endpoint::ARTISTS_ID_TOP_TRACKS, compact('id'), market: $market), @@ -74,7 +74,7 @@ public function getTopTracks(string $id, string $market = 'US'): array /** @return Artist[] */ public function getRelatedArtists(string $id): array { - return $this->http->arrayMapRequest( + return $this->http->mapArrayRequest( Artist::class, Method::GET, Endpoint::bind(Endpoint::ARTISTS_ID_RELATED_ARTISTS, compact('id')), diff --git a/src/Rest/Audiobooks.php b/src/Rest/Audiobooks.php new file mode 100644 index 0000000..2dba509 --- /dev/null +++ b/src/Rest/Audiobooks.php @@ -0,0 +1,44 @@ +http->mapRequest( + Audiobook::class, + Method::GET, + Endpoint::bind(Endpoint::AUDIOBOOKS_ID, compact('id'), market: $market), + headers: $this->http->mergeHeaders() + ); + } + + /** @return Audiobook[] */ + public function getSeveral(array $ids, ?string $market = null): array + { + $ids = implode(',', $ids); + + return $this->http->mapArrayRequest( + Audiobook::class, + Method::GET, + Endpoint::bind(Endpoint::AUDIOBOOKS, getParams: compact('ids'), market: $market), + headers: $this->http->mergeHeaders(), + callback: static fn (array $res) => $res['audiobooks'] + ); + } + + public function getChapters(string $id, int $limit = 20, int $offset = 0, ?string $market = null): Chapters + { + return $this->http->mapRequest( + Chapters::class, + Method::GET, + Endpoint::bind(Endpoint::AUDIOBOOKS_CHAPTERS, compact('id'), getParams: compact('limit', 'offset'), market: $market), + headers: $this->http->mergeHeaders() + ); + } +} diff --git a/src/Rest/Endpoint.php b/src/Rest/Endpoint.php index 54b1406..d8a747b 100644 --- a/src/Rest/Endpoint.php +++ b/src/Rest/Endpoint.php @@ -20,10 +20,18 @@ class Endpoint public const TRACK_AUDIO_FEATURES = '/audio-features'; public const TRACK_AUDIO_FEATURES_ID = '/audio-features/:id:'; public const PLAYLISTS_ID = '/playlists/:id:'; + public const AUDIOBOOKS_ID = '/audiobooks/:id:'; + public const AUDIOBOOKS_CHAPTERS = '/audiobooks/:id:/chapters'; + public const AUDIOBOOKS = '/audiobooks'; + public const USERS_ME = '/me'; + public const USERS_ME_TOP_ARTISTS = '/me/top/artists'; + public const USERS_ME_TOP_TRACKS = '/me/top/tracks'; + public const USERS_ME_PLAYLISTS = '/me/playlists'; + public const USERS_ME_PLAYBACK = '/me/player'; public static function bind(string $endpoint, array $params = [], array $getParams = [], ?string $market = null): string { - $endpoint = self::BASE.$endpoint; + $endpoint = self::BASE . $endpoint; foreach ($params as $key => $value) { $endpoint = str_replace(":{$key}:", $value, $endpoint); @@ -34,7 +42,7 @@ public static function bind(string $endpoint, array $params = [], array $getPara } if (!empty($getParams)) { - $endpoint .= '?'.http_build_query($getParams, '', '&'); + $endpoint .= '?' . http_build_query($getParams, '', '&'); } return $endpoint; diff --git a/src/Rest/Player.php b/src/Rest/Player.php new file mode 100644 index 0000000..2b1feb1 --- /dev/null +++ b/src/Rest/Player.php @@ -0,0 +1,28 @@ +http->request( + Method::GET, + Endpoint::bind(Endpoint::USERS_ME_PLAYBACK), + headers: $this->http->mergeHeaders() + ); + + $body = json_decode($request->getBody()->getContents(), true); + + if (!$body) { + return null; + } + + return map(State::class, $body); + } +} \ No newline at end of file diff --git a/src/Rest/Playlists.php b/src/Rest/Playlists.php index 1b0db6b..6691d38 100644 --- a/src/Rest/Playlists.php +++ b/src/Rest/Playlists.php @@ -3,6 +3,7 @@ namespace Tnapf\Spotify\Rest; use Tnapf\Spotify\Abstractions\Playlist\Playlist; +use Tnapf\Spotify\Abstractions\Playlist\SimplifiedPlaylists; use Tnapf\Spotify\Enums\Method; class Playlists extends RestBase @@ -17,6 +18,15 @@ public function get(string $id, ?string $market = null, ?string $fields = null, ); } + public function getCurrentUserPlaylists(int $limit = 20, $offset = 0): SimplifiedPlaylists { + return $this->http->mapRequest( + SimplifiedPlaylists::class, + Method::GET, + Endpoint::bind(Endpoint::USERS_ME_PLAYLISTS, getParams: compact('limit', 'offset')), + headers: $this->http->mergeHeaders() + ); + } + public function changeDetails(string $id, ?string $name = null, ?bool $public = null, ?bool $collaborative = null, ?string $description = null): bool { $body = array_filter( @@ -27,9 +37,8 @@ public function changeDetails(string $id, ?string $name = null, ?bool $public = $this->http->request( Method::PUT, Endpoint::bind(Endpoint::PLAYLISTS_ID, compact('id')), - body: compact('name', 'public', 'collaborative', 'description'), + body: json_encode($body), headers: $this->http->mergeHeaders() ); - } } diff --git a/src/Rest/Tracks.php b/src/Rest/Tracks.php index 6ca70c4..9ec482e 100644 --- a/src/Rest/Tracks.php +++ b/src/Rest/Tracks.php @@ -24,7 +24,7 @@ public function getSeveral(array $ids, ?string $market = null): array { $ids = implode(',', $ids); - return $this->http->arrayMapRequest( + return $this->http->mapArrayRequest( Track::class, Method::GET, Endpoint::bind(Endpoint::TRACKS, getParams: compact('ids'), market: $market), @@ -48,7 +48,7 @@ public function getSeveralAudioFeatures(array $ids): array { $ids = implode(',', $ids); - return $this->http->arrayMapRequest( + return $this->http->mapArrayRequest( AudioFeatures::class, Method::GET, Endpoint::bind(Endpoint::TRACK_AUDIO_FEATURES, getParams: compact('ids')), diff --git a/src/Rest/Users.php b/src/Rest/Users.php new file mode 100644 index 0000000..7e4bb4d --- /dev/null +++ b/src/Rest/Users.php @@ -0,0 +1,45 @@ +http->mapRequest( + User::class, + Method::GET, + Endpoint::bind(Endpoint::USERS_ME), + headers: $this->http->mergeHeaders() + ); + } + + public function getTopItems(TopItemType $type, TopItemTimeRange $timeRange = TopItemTimeRange::MEDIUM_TERM, int $limit = 20, int $offset = 0): Tracks|Artists + { + $class = match($type) { + TopItemType::TRACKS => Tracks::class, + TopItemType::ARTISTS => Artists::class, + }; + + $url = match($type) { + TopItemType::TRACKS => Endpoint::USERS_ME_TOP_TRACKS, + TopItemType::ARTISTS => Endpoint::USERS_ME_TOP_ARTISTS, + }; + + $time_range = $timeRange->value; + + return $this->http->mapRequest( + $class, + Method::GET, + Endpoint::bind($url, getParams: compact('time_range', 'limit', 'offset')), + headers: $this->http->mergeHeaders() + ); + } +} \ No newline at end of file