diff --git a/app/Values/Concerns/FormatsLastFmText.php b/app/Http/Integrations/Concerns/FormatsLastFmText.php similarity index 92% rename from app/Values/Concerns/FormatsLastFmText.php rename to app/Http/Integrations/Concerns/FormatsLastFmText.php index d8114272..d70c7515 100644 --- a/app/Values/Concerns/FormatsLastFmText.php +++ b/app/Http/Integrations/Concerns/FormatsLastFmText.php @@ -1,6 +1,6 @@ */ + protected function defaultQuery(): array + { + return [ + 'api_key' => config('koel.lastfm.key'), + 'method' => 'album.getInfo', + 'artist' => $this->album->artist->name, + 'album' => $this->album->name, + 'autocorrect' => 1, + 'format' => 'json', + ]; + } + + public function createDtoFromResponse(Response $response): ?AlbumInformation + { + $album = object_get($response->object(), 'album'); + + if (!$album) { + return null; + } + + return AlbumInformation::make( + url: object_get($album, 'url'), + cover: Arr::get(object_get($album, 'image', []), '0.#text'), + wiki: [ + 'summary' => self::formatLastFmText(object_get($album, 'wiki.summary')), + 'full' => self::formatLastFmText(object_get($album, 'wiki.content')), + ], + tracks: array_map(static fn ($track): array => [ + 'title' => $track->name, + 'length' => (int) $track->duration, + 'url' => $track->url, + ], object_get($album, 'tracks.track', [])) + ); + } +} diff --git a/app/Http/Integrations/Lastfm/Requests/GetArtistInfoRequest.php b/app/Http/Integrations/Lastfm/Requests/GetArtistInfoRequest.php new file mode 100644 index 00000000..a1fc348c --- /dev/null +++ b/app/Http/Integrations/Lastfm/Requests/GetArtistInfoRequest.php @@ -0,0 +1,55 @@ + */ + protected function defaultQuery(): array + { + return [ + 'api_key' => config('koel.lastfm.key'), + 'method' => 'artist.getInfo', + 'artist' => $this->artist->name, + 'autocorrect' => 1, + 'format' => 'json', + ]; + } + + public function createDtoFromResponse(Response $response): ?ArtistInformation + { + $artist = object_get($response->object(), 'artist'); + + if (!$artist) { + return null; + } + + return ArtistInformation::make( + url: object_get($artist, 'url'), + bio: [ + 'summary' => self::formatLastFmText(object_get($artist, 'bio.summary')), + 'full' => self::formatLastFmText(object_get($artist, 'bio.content')), + ], + ); + } +} diff --git a/app/Http/Integrations/Lastfm/Requests/GetSessionKeyRequest.php b/app/Http/Integrations/Lastfm/Requests/GetSessionKeyRequest.php new file mode 100644 index 00000000..7ad3e011 --- /dev/null +++ b/app/Http/Integrations/Lastfm/Requests/GetSessionKeyRequest.php @@ -0,0 +1,31 @@ + */ + protected function defaultQuery(): array + { + return [ + 'api_key' => config('koel.lastfm.key'), + 'method' => 'auth.getSession', + 'token' => $this->token, + 'format' => 'json', + ]; + } +} diff --git a/app/Http/Integrations/Lastfm/Requests/ScrobbleRequest.php b/app/Http/Integrations/Lastfm/Requests/ScrobbleRequest.php new file mode 100644 index 00000000..ae5c2194 --- /dev/null +++ b/app/Http/Integrations/Lastfm/Requests/ScrobbleRequest.php @@ -0,0 +1,46 @@ + */ + protected function defaultBody(): array + { + $body = [ + 'api_key' => config('koel.lastfm.key'), + 'method' => 'track.scrobble', + 'artist' => $this->song->artist->name, + 'track' => $this->song->title, + 'timestamp' => $this->timestamp, + 'sk' => $this->user->preferences->lastFmSessionKey, + ]; + + if ($this->song->album->name !== Album::UNKNOWN_NAME) { + $body['album'] = $this->song->album->name; + } + + return $body; + } +} diff --git a/app/Http/Integrations/Lastfm/Requests/SignedRequest.php b/app/Http/Integrations/Lastfm/Requests/SignedRequest.php new file mode 100644 index 00000000..01cdf6f8 --- /dev/null +++ b/app/Http/Integrations/Lastfm/Requests/SignedRequest.php @@ -0,0 +1,48 @@ +middleware()->onRequest(fn (PendingRequest $request) => $this->sign($request)); + } + + protected function sign(PendingRequest $request): void + { + if ($request->body() instanceof FormBodyRepository) { + $request->body()->add('api_sig', self::createSignature($request->body()->all())); + + return; + } + + $request->query()->add('api_sig', self::createSignature($request->query()->all())); + } + + private static function createSignature(array $parameters): string + { + ksort($parameters); + + // Generate the API signature. + // @link http://www.last.fm/api/webauth#6 + $str = ''; + + foreach ($parameters as $name => $value) { + if ($name === 'format') { + // The format parameter is not part of the signature. + continue; + } + + $str .= $name . $value; + } + + $str .= config('koel.lastfm.secret'); + + return md5($str); + } +} diff --git a/app/Http/Integrations/Lastfm/Requests/ToggleLoveTrackRequest.php b/app/Http/Integrations/Lastfm/Requests/ToggleLoveTrackRequest.php new file mode 100644 index 00000000..41e555be --- /dev/null +++ b/app/Http/Integrations/Lastfm/Requests/ToggleLoveTrackRequest.php @@ -0,0 +1,38 @@ + */ + protected function defaultBody(): array + { + return [ + 'api_key' => config('koel.lastfm.key'), + 'method' => $this->love ? 'track.love' : 'track.unlove', + 'sk' => $this->user->preferences->lastFmSessionKey, + 'artist' => $this->song->artist->name, + 'track' => $this->song->title, + ]; + } +} diff --git a/app/Http/Integrations/Lastfm/Requests/UpdateNowPlayingRequest.php b/app/Http/Integrations/Lastfm/Requests/UpdateNowPlayingRequest.php new file mode 100644 index 00000000..d9d5febe --- /dev/null +++ b/app/Http/Integrations/Lastfm/Requests/UpdateNowPlayingRequest.php @@ -0,0 +1,46 @@ + */ + protected function defaultBody(): array + { + $parameters = [ + 'api_key' => config('koel.lastfm.key'), + 'method' => 'track.updateNowPlaying', + 'artist' => $this->song->artist->name, + 'track' => $this->song->title, + 'duration' => $this->song->length, + 'sk' => $this->user->preferences->lastFmSessionKey, + ]; + + if ($this->song->album->name !== Album::UNKNOWN_NAME) { + $parameters['album'] = $this->song->album->name; + } + + return $parameters; + } +} diff --git a/app/Services/LastfmService.php b/app/Services/LastfmService.php index c98c0e54..4a69a8b1 100644 --- a/app/Services/LastfmService.php +++ b/app/Services/LastfmService.php @@ -2,21 +2,26 @@ namespace App\Services; +use App\Http\Integrations\Lastfm\LastfmConnector; +use App\Http\Integrations\Lastfm\Requests\GetAlbumInfoRequest; +use App\Http\Integrations\Lastfm\Requests\GetArtistInfoRequest; +use App\Http\Integrations\Lastfm\Requests\GetSessionKeyRequest; +use App\Http\Integrations\Lastfm\Requests\ScrobbleRequest; +use App\Http\Integrations\Lastfm\Requests\ToggleLoveTrackRequest; +use App\Http\Integrations\Lastfm\Requests\UpdateNowPlayingRequest; use App\Models\Album; use App\Models\Artist; use App\Models\Song; use App\Models\User; -use App\Services\ApiClients\LastfmClient; use App\Services\Contracts\MusicEncyclopedia; use App\Values\AlbumInformation; use App\Values\ArtistInformation; -use GuzzleHttp\Promise\Promise; -use GuzzleHttp\Promise\Utils; +use Generator; use Illuminate\Support\Collection; class LastfmService implements MusicEncyclopedia { - public function __construct(private LastfmClient $client) + public function __construct(private LastfmConnector $connector) { } @@ -43,10 +48,7 @@ class LastfmService implements MusicEncyclopedia } return attempt_if(static::enabled(), function () use ($artist): ?ArtistInformation { - $name = urlencode($artist->name); - $response = $this->client->get("?method=artist.getInfo&autocorrect=1&artist=$name&format=json"); - - return isset($response?->artist) ? ArtistInformation::fromLastFmData($response->artist) : null; + return $this->connector->send(new GetArtistInfoRequest($artist))->dto(); }); } @@ -57,41 +59,18 @@ class LastfmService implements MusicEncyclopedia } return attempt_if(static::enabled(), function () use ($album): ?AlbumInformation { - $albumName = urlencode($album->name); - $artistName = urlencode($album->artist->name); - - $response = $this->client - ->get("?method=album.getInfo&autocorrect=1&album=$albumName&artist=$artistName&format=json"); - - return isset($response?->album) ? AlbumInformation::fromLastFmData($response->album) : null; + return $this->connector->send(new GetAlbumInfoRequest($album))->dto(); }); } public function scrobble(Song $song, User $user, int $timestamp): void { - $params = [ - 'artist' => $song->artist->name, - 'track' => $song->title, - 'timestamp' => $timestamp, - 'sk' => $user->preferences->lastFmSessionKey, - 'method' => 'track.scrobble', - ]; - - if ($song->album->name !== Album::UNKNOWN_NAME) { - $params['album'] = $song->album->name; - } - - attempt(fn () => $this->client->post('/', $params, false)); + attempt(fn () => $this->connector->send(new ScrobbleRequest($song, $user, $timestamp))); } public function toggleLoveTrack(Song $song, User $user, bool $love): void { - attempt(fn () => $this->client->post('/', [ - 'track' => $song->title, - 'artist' => $song->artist->name, - 'sk' => $user->preferences->lastFmSessionKey, - 'method' => $love ? 'track.love' : 'track.unlove', - ], false)); + attempt(fn () => $this->connector->send(new ToggleLoveTrackRequest($song, $user, $love))); } /** @@ -99,40 +78,26 @@ class LastfmService implements MusicEncyclopedia */ public function batchToggleLoveTracks(Collection $songs, User $user, bool $love): void { - $promises = $songs->map( - function (Song $song) use ($user, $love): Promise { - return $this->client->postAsync('/', [ - 'track' => $song->title, - 'artist' => $song->artist->name, - 'sk' => $user->preferences->lastFmSessionKey, - 'method' => $love ? 'track.love' : 'track.unlove', - ], false); + $generatorCallback = static function () use ($songs, $user, $love): Generator { + foreach ($songs as $song) { + yield new ToggleLoveTrackRequest($song, $user, $love); } - ); + }; - attempt(static fn () => Utils::unwrap($promises)); + $this->connector + ->pool($generatorCallback) + ->send() + ->wait(); } public function updateNowPlaying(Song $song, User $user): void { - $params = [ - 'artist' => $song->artist->name, - 'track' => $song->title, - 'duration' => $song->length, - 'sk' => $user->preferences->lastFmSessionKey, - 'method' => 'track.updateNowPlaying', - ]; - - if ($song->album->name !== Album::UNKNOWN_NAME) { - $params['album'] = $song->album->name; - } - - attempt(fn () => $this->client->post('/', $params, false)); + attempt(fn () => $this->connector->send(new UpdateNowPlayingRequest($song, $user))); } public function getSessionKey(string $token): ?string { - return $this->client->getSessionKey($token); + return object_get($this->connector->send(new GetSessionKeyRequest($token))->object(), 'session.key'); } public function setUserSessionKey(User $user, ?string $sessionKey): void diff --git a/app/Values/AlbumInformation.php b/app/Values/AlbumInformation.php index 9bbc67bd..adda9497 100644 --- a/app/Values/AlbumInformation.php +++ b/app/Values/AlbumInformation.php @@ -2,14 +2,10 @@ namespace App\Values; -use App\Values\Concerns\FormatsLastFmText; use Illuminate\Contracts\Support\Arrayable; -use Illuminate\Support\Arr; final class AlbumInformation implements Arrayable { - use FormatsLastFmText; - public const JSON_STRUCTURE = [ 'url', 'cover', @@ -39,23 +35,6 @@ final class AlbumInformation implements Arrayable return new self($url, $cover, $wiki, $tracks); } - public static function fromLastFmData(object $data): self - { - return self::make( - url: $data->url, - cover: Arr::get($data->image, '0.#text'), - wiki: [ - 'summary' => isset($data->wiki) ? self::formatLastFmText($data->wiki->summary) : '', - 'full' => isset($data->wiki) ? self::formatLastFmText($data->wiki->content) : '', - ], - tracks: array_map(static fn ($track): array => [ - 'title' => $track->name, - 'length' => (int) $track->duration, - 'url' => $track->url, - ], $data->tracks?->track ?? []), - ); - } - /** @return array */ public function toArray(): array { diff --git a/app/Values/ArtistInformation.php b/app/Values/ArtistInformation.php index 8bee9aaa..eda9fba6 100644 --- a/app/Values/ArtistInformation.php +++ b/app/Values/ArtistInformation.php @@ -2,13 +2,10 @@ namespace App\Values; -use App\Values\Concerns\FormatsLastFmText; use Illuminate\Contracts\Support\Arrayable; final class ArtistInformation implements Arrayable { - use FormatsLastFmText; - public const JSON_STRUCTURE = [ 'url', 'image', @@ -30,17 +27,6 @@ final class ArtistInformation implements Arrayable return new self($url, $image, $bio); } - public static function fromLastFmData(object $data): self - { - return self::make( - url: $data->url, - bio: [ - 'summary' => isset($data->bio) ? self::formatLastFmText($data->bio->summary) : '', - 'full' => isset($data->bio) ? self::formatLastFmText($data->bio->content) : '', - ], - ); - } - /** @return array */ public function toArray(): array { diff --git a/composer.json b/composer.json index d87942df..c62c3d62 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,9 @@ "meilisearch/meilisearch-php": "^0.24.0", "http-interop/http-factory-guzzle": "^1.2", "league/flysystem-aws-s3-v3": "^3.0", - "spatie/flysystem-dropbox": "^3.0" + "spatie/flysystem-dropbox": "^3.0", + "saloonphp/saloon": "^3.8", + "saloonphp/laravel-plugin": "^3.0" }, "require-dev": { "mockery/mockery": "~1.0", diff --git a/composer.lock b/composer.lock index f37a6134..bc116323 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7cf7d2bb1b51df5f49ca061698b6c4be", + "content-hash": "05ad56210e2f5ed90da83428f2777175", "packages": [ { "name": "algolia/algoliasearch-client-php", @@ -5359,6 +5359,156 @@ ], "time": "2023-01-12T18:13:24+00:00" }, + { + "name": "saloonphp/laravel-plugin", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/saloonphp/laravel-plugin.git", + "reference": "017fc93a2af15ccb94840b9ea772c9f4e95e2917" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/saloonphp/laravel-plugin/zipball/017fc93a2af15ccb94840b9ea772c9f4e95e2917", + "reference": "017fc93a2af15ccb94840b9ea772c9f4e95e2917", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52 || ^10.0", + "illuminate/http": "^9.52 || ^10.0", + "illuminate/support": "^9.52 || ^10.0", + "php": "^8.1", + "saloonphp/saloon": "^3.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.5", + "orchestra/testbench": "^v7.30 || ^v8.10", + "pestphp/pest": "^v1.23", + "phpstan/phpstan": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Saloon": "Saloon\\Laravel\\Facades\\Saloon" + }, + "providers": [ + "Saloon\\Laravel\\SaloonServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Saloon\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sam Carré", + "email": "29132017+Sammyjo20@users.noreply.github.com", + "role": "Developer" + } + ], + "description": "Laravel plugin for Saloon", + "homepage": "https://github.com/saloonphp/laravel-plugin", + "keywords": [ + "api", + "api-integrations", + "saloon", + "sammyjo20", + "sdk" + ], + "support": { + "issues": "https://github.com/saloonphp/laravel-plugin/issues", + "source": "https://github.com/saloonphp/laravel-plugin/tree/v3.0.0" + }, + "time": "2023-10-02T16:28:57+00:00" + }, + { + "name": "saloonphp/saloon", + "version": "v3.8.0", + "source": { + "type": "git", + "url": "https://github.com/saloonphp/saloon.git", + "reference": "e669e8554f275c83aaa0b7d9d219b442e269f75f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/saloonphp/saloon/zipball/e669e8554f275c83aaa0b7d9d219b442e269f75f", + "reference": "e669e8554f275c83aaa0b7d9d219b442e269f75f", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.6", + "guzzlehttp/promises": "^1.5 || ^2.0", + "guzzlehttp/psr7": "^2.0", + "php": "^8.1", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "conflict": { + "sammyjo20/saloon": "*" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "illuminate/collections": "^9.39 || ^10.0", + "league/flysystem": "^3.0", + "pestphp/pest": "^2.6", + "phpstan/phpstan": "^1.9", + "saloonphp/xml-wrangler": "^1.1", + "spatie/ray": "^1.33", + "symfony/dom-crawler": "^6.0 || ^7.0", + "symfony/var-dumper": "^6.3 || ^7.0" + }, + "suggest": { + "illuminate/collections": "Required for the response collect() method.", + "saloonphp/xml-wrangler": "Required for the response xmlReader() method.", + "symfony/dom-crawler": "Required for the response dom() method.", + "symfony/var-dumper": "Required for default debugging drivers." + }, + "type": "library", + "autoload": { + "psr-4": { + "Saloon\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sam Carré", + "email": "29132017+Sammyjo20@users.noreply.github.com", + "role": "Developer" + } + ], + "description": "Build beautiful API integrations and SDKs with Saloon", + "homepage": "https://github.com/saloonphp/saloon", + "keywords": [ + "api", + "api-integrations", + "saloon", + "sammyjo20", + "sdk" + ], + "support": { + "issues": "https://github.com/saloonphp/saloon/issues", + "source": "https://github.com/saloonphp/saloon/tree/v3.8.0" + }, + "funding": [ + { + "url": "https://github.com/sammyjo20", + "type": "github" + } + ], + "time": "2024-03-20T23:14:31+00:00" + }, { "name": "spatie/dropbox-api", "version": "1.22.0", diff --git a/config/saloon.php b/config/saloon.php new file mode 100644 index 00000000..c9b9e3ab --- /dev/null +++ b/config/saloon.php @@ -0,0 +1,38 @@ + GuzzleSender::class, + + /* + |-------------------------------------------------------------------------- + | Integrations Path + |-------------------------------------------------------------------------- + | + | By default, this package will create any classes within + | `/app/Http/Integrations` directory. If you're using + | a different design approach, then your classes + | may be in a different place. You can change + | that location so that the saloon:list + | command will still find your files + | + */ + + 'integrations_path' => base_path('App/Http/Integrations'), +];