feat: use Saloon for Last.fm integration

This commit is contained in:
Phan An 2024-03-22 13:55:25 +01:00
parent 9238ecfd44
commit 06dfe8a1db
15 changed files with 559 additions and 96 deletions

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Values\Concerns; namespace App\Http\Integrations\Concerns;
trait FormatsLastFmText trait FormatsLastFmText
{ {

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Integrations\Lastfm;
use Saloon\Http\Connector;
use Saloon\Traits\Plugins\AcceptsJson;
class LastfmConnector extends Connector
{
use AcceptsJson;
public function resolveBaseUrl(): string
{
return config('koel.lastfm.endpoint');
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Integrations\Lastfm\Requests;
use App\Http\Integrations\Concerns\FormatsLastFmText;
use App\Models\Album;
use App\Values\AlbumInformation;
use Illuminate\Support\Arr;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Response;
final class GetAlbumInfoRequest extends Request
{
use FormatsLastFmText;
protected Method $method = Method::GET;
public function __construct(private Album $album)
{
}
public function resolveEndpoint(): string
{
return '/';
}
/** @return array<mixed> */
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', []))
);
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Integrations\Lastfm\Requests;
use App\Http\Integrations\Concerns\FormatsLastFmText;
use App\Models\Artist;
use App\Values\ArtistInformation;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Response;
final class GetArtistInfoRequest extends Request
{
use FormatsLastFmText;
protected Method $method = Method::GET;
public function __construct(private Artist $artist)
{
}
public function resolveEndpoint(): string
{
return '/';
}
/** @return array<mixed> */
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')),
],
);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Integrations\Lastfm\Requests;
use Saloon\Enums\Method;
final class GetSessionKeyRequest extends SignedRequest
{
protected Method $method = Method::GET;
public function __construct(private string $token)
{
parent::__construct();
}
public function resolveEndpoint(): string
{
return '/';
}
/** @return array<mixed> */
protected function defaultQuery(): array
{
return [
'api_key' => config('koel.lastfm.key'),
'method' => 'auth.getSession',
'token' => $this->token,
'format' => 'json',
];
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Http\Integrations\Lastfm\Requests;
use App\Models\Album;
use App\Models\Song;
use App\Models\User;
use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Traits\Body\HasFormBody;
final class ScrobbleRequest extends SignedRequest implements HasBody
{
use HasFormBody;
protected Method $method = Method::POST;
public function __construct(private Song $song, private User $user, private int $timestamp)
{
parent::__construct();
}
public function resolveEndpoint(): string
{
return '/';
}
/** @return array<mixed> */
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;
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Http\Integrations\Lastfm\Requests;
use Saloon\Http\PendingRequest;
use Saloon\Http\Request;
use Saloon\Repositories\Body\FormBodyRepository;
abstract class SignedRequest extends Request
{
public function __construct()
{
$this->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);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Integrations\Lastfm\Requests;
use App\Models\Song;
use App\Models\User;
use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Traits\Body\HasFormBody;
final class ToggleLoveTrackRequest extends SignedRequest implements HasBody
{
use HasFormBody;
protected Method $method = Method::POST;
public function __construct(private Song $song, private User $user, private bool $love)
{
parent::__construct();
}
public function resolveEndpoint(): string
{
return '/';
}
/** @return array<mixed> */
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,
];
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Http\Integrations\Lastfm\Requests;
use App\Models\Album;
use App\Models\Song;
use App\Models\User;
use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Traits\Body\HasFormBody;
final class UpdateNowPlayingRequest extends SignedRequest implements HasBody
{
use HasFormBody;
protected Method $method = Method::POST;
public function __construct(private Song $song, private User $user)
{
parent::__construct();
}
public function resolveEndpoint(): string
{
return '/';
}
/** @return array<mixed> */
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;
}
}

View file

@ -2,21 +2,26 @@
namespace App\Services; 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\Album;
use App\Models\Artist; use App\Models\Artist;
use App\Models\Song; use App\Models\Song;
use App\Models\User; use App\Models\User;
use App\Services\ApiClients\LastfmClient;
use App\Services\Contracts\MusicEncyclopedia; use App\Services\Contracts\MusicEncyclopedia;
use App\Values\AlbumInformation; use App\Values\AlbumInformation;
use App\Values\ArtistInformation; use App\Values\ArtistInformation;
use GuzzleHttp\Promise\Promise; use Generator;
use GuzzleHttp\Promise\Utils;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class LastfmService implements MusicEncyclopedia 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 { return attempt_if(static::enabled(), function () use ($artist): ?ArtistInformation {
$name = urlencode($artist->name); return $this->connector->send(new GetArtistInfoRequest($artist))->dto();
$response = $this->client->get("?method=artist.getInfo&autocorrect=1&artist=$name&format=json");
return isset($response?->artist) ? ArtistInformation::fromLastFmData($response->artist) : null;
}); });
} }
@ -57,41 +59,18 @@ class LastfmService implements MusicEncyclopedia
} }
return attempt_if(static::enabled(), function () use ($album): ?AlbumInformation { return attempt_if(static::enabled(), function () use ($album): ?AlbumInformation {
$albumName = urlencode($album->name); return $this->connector->send(new GetAlbumInfoRequest($album))->dto();
$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;
}); });
} }
public function scrobble(Song $song, User $user, int $timestamp): void public function scrobble(Song $song, User $user, int $timestamp): void
{ {
$params = [ attempt(fn () => $this->connector->send(new ScrobbleRequest($song, $user, $timestamp)));
'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));
} }
public function toggleLoveTrack(Song $song, User $user, bool $love): void public function toggleLoveTrack(Song $song, User $user, bool $love): void
{ {
attempt(fn () => $this->client->post('/', [ attempt(fn () => $this->connector->send(new ToggleLoveTrackRequest($song, $user, $love)));
'track' => $song->title,
'artist' => $song->artist->name,
'sk' => $user->preferences->lastFmSessionKey,
'method' => $love ? 'track.love' : 'track.unlove',
], false));
} }
/** /**
@ -99,40 +78,26 @@ class LastfmService implements MusicEncyclopedia
*/ */
public function batchToggleLoveTracks(Collection $songs, User $user, bool $love): void public function batchToggleLoveTracks(Collection $songs, User $user, bool $love): void
{ {
$promises = $songs->map( $generatorCallback = static function () use ($songs, $user, $love): Generator {
function (Song $song) use ($user, $love): Promise { foreach ($songs as $song) {
return $this->client->postAsync('/', [ yield new ToggleLoveTrackRequest($song, $user, $love);
'track' => $song->title,
'artist' => $song->artist->name,
'sk' => $user->preferences->lastFmSessionKey,
'method' => $love ? 'track.love' : 'track.unlove',
], false);
} }
); };
attempt(static fn () => Utils::unwrap($promises)); $this->connector
->pool($generatorCallback)
->send()
->wait();
} }
public function updateNowPlaying(Song $song, User $user): void public function updateNowPlaying(Song $song, User $user): void
{ {
$params = [ attempt(fn () => $this->connector->send(new UpdateNowPlayingRequest($song, $user)));
'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));
} }
public function getSessionKey(string $token): ?string 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 public function setUserSessionKey(User $user, ?string $sessionKey): void

View file

@ -2,14 +2,10 @@
namespace App\Values; namespace App\Values;
use App\Values\Concerns\FormatsLastFmText;
use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Arr;
final class AlbumInformation implements Arrayable final class AlbumInformation implements Arrayable
{ {
use FormatsLastFmText;
public const JSON_STRUCTURE = [ public const JSON_STRUCTURE = [
'url', 'url',
'cover', 'cover',
@ -39,23 +35,6 @@ final class AlbumInformation implements Arrayable
return new self($url, $cover, $wiki, $tracks); 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<mixed> */ /** @return array<mixed> */
public function toArray(): array public function toArray(): array
{ {

View file

@ -2,13 +2,10 @@
namespace App\Values; namespace App\Values;
use App\Values\Concerns\FormatsLastFmText;
use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Arrayable;
final class ArtistInformation implements Arrayable final class ArtistInformation implements Arrayable
{ {
use FormatsLastFmText;
public const JSON_STRUCTURE = [ public const JSON_STRUCTURE = [
'url', 'url',
'image', 'image',
@ -30,17 +27,6 @@ final class ArtistInformation implements Arrayable
return new self($url, $image, $bio); 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<mixed> */ /** @return array<mixed> */
public function toArray(): array public function toArray(): array
{ {

View file

@ -37,7 +37,9 @@
"meilisearch/meilisearch-php": "^0.24.0", "meilisearch/meilisearch-php": "^0.24.0",
"http-interop/http-factory-guzzle": "^1.2", "http-interop/http-factory-guzzle": "^1.2",
"league/flysystem-aws-s3-v3": "^3.0", "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": { "require-dev": {
"mockery/mockery": "~1.0", "mockery/mockery": "~1.0",

152
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7cf7d2bb1b51df5f49ca061698b6c4be", "content-hash": "05ad56210e2f5ed90da83428f2777175",
"packages": [ "packages": [
{ {
"name": "algolia/algoliasearch-client-php", "name": "algolia/algoliasearch-client-php",
@ -5359,6 +5359,156 @@
], ],
"time": "2023-01-12T18:13:24+00:00" "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", "name": "spatie/dropbox-api",
"version": "1.22.0", "version": "1.22.0",

38
config/saloon.php Normal file
View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Saloon\Http\Senders\GuzzleSender;
return [
/*
|--------------------------------------------------------------------------
| Default Saloon Sender
|--------------------------------------------------------------------------
|
| This value specifies the "sender" class that Saloon should use by
| default on all connectors. You can change this sender if you
| would like to use your own. You may also specify your own
| sender on a per-connector basis.
|
*/
'default_sender' => 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'),
];