mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: improve Spotify integration
This commit is contained in:
parent
5ecfc89aa6
commit
cebbf13107
25 changed files with 607 additions and 235 deletions
24
.env.example
24
.env.example
|
@ -6,7 +6,7 @@ APP_NAME=Koel
|
|||
# pgsql (PostgreSQL)
|
||||
# sqlsrv (Microsoft SQL Server)
|
||||
# sqlite-persistent (Local sqlite file)
|
||||
# IMPORTANT: This value must present for artisan koel:init command to work.
|
||||
# IMPORTANT: This value must present for `artisan koel:init` command to work.
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
|
@ -49,13 +49,24 @@ MEMORY_LIMIT=
|
|||
STREAMING_METHOD=php
|
||||
|
||||
|
||||
# If you want Koel to integrate with Last.fm, set the API details here.
|
||||
# See https://docs.koel.dev/3rd-party.html#last-fm for more information
|
||||
# Last.fm API can be used to fetch artist and album information, as well as to
|
||||
# allow users to connect to their Last.fm account and scrobble.
|
||||
# To integrate Koel with Last.fm, create an API account at
|
||||
# https://www.last.fm/api/account/create and set the credentials here.
|
||||
# Consult Koel's doc for more information.
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_API_SECRET=
|
||||
|
||||
|
||||
# If you want to use Amazon S3 with Koel, fill the info here and follow the
|
||||
# Spotify API can be used to fetch artist and album images.
|
||||
# To integrate Koel with Spotify, create a Spotify application at
|
||||
# https://developer.spotify.com/dashboard/applications and set the credentials here.
|
||||
# Consult Koel's doc for more information.
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
|
||||
|
||||
# To use Amazon S3 with Koel, fill the info here and follow the
|
||||
# installation guide at https://docs.koel.dev/aws-s3.html
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
@ -63,7 +74,7 @@ AWS_REGION=
|
|||
AWS_ENDPOINT=
|
||||
|
||||
|
||||
# If you want Koel to integrate with YouTube, set the API key here.
|
||||
# To integrate Koel with YouTube, set the API key here.
|
||||
# See https://docs.koel.dev/3rd-party.html#youtube for more information.
|
||||
YOUTUBE_API_KEY=
|
||||
|
||||
|
@ -74,8 +85,7 @@ YOUTUBE_API_KEY=
|
|||
CDN_URL=
|
||||
|
||||
|
||||
# If you want to transcode FLAC to MP3 and stream it on the fly, make sure the
|
||||
# following settings are sane.
|
||||
# To transcode FLAC to MP3 and stream it on the fly, make sure the following settings are sane.
|
||||
|
||||
# The full path of ffmpeg binary.
|
||||
FFMPEG_PATH=/usr/local/bin/ffmpeg
|
||||
|
|
19
app/Exceptions/SpotifyIntegrationDisabledException.php
Normal file
19
app/Exceptions/SpotifyIntegrationDisabledException.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
class SpotifyIntegrationDisabledException extends Exception
|
||||
{
|
||||
private function __construct(string $message = '', int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
return new self('Spotify integration is disabled.');
|
||||
}
|
||||
}
|
|
@ -14,48 +14,24 @@ function static_url(?string $name = null): string
|
|||
return $cdnUrl ? $cdnUrl . '/' . trim(ltrim($name, '/')) : trim(asset($name));
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of Laravel Mix but catered to our directory structure.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
function asset_rev(string $file, ?string $manifestFile = null): string
|
||||
function album_cover_path(?string $fileName): ?string
|
||||
{
|
||||
static $manifest = null;
|
||||
|
||||
$manifestFile = $manifestFile ?: public_path('mix-manifest.json');
|
||||
|
||||
if ($manifest === null) {
|
||||
$manifest = json_decode(file_get_contents($manifestFile), true);
|
||||
}
|
||||
|
||||
if (isset($manifest[$file])) {
|
||||
return file_exists(public_path('hot'))
|
||||
? "http://localhost:8080$manifest[$file]"
|
||||
: static_url($manifest[$file]);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("File $file not defined in asset manifest.");
|
||||
return $fileName ? public_path(config('koel.album_cover_dir') . $fileName) : null;
|
||||
}
|
||||
|
||||
function album_cover_path(string $fileName): string
|
||||
function album_cover_url(?string $fileName): ?string
|
||||
{
|
||||
return public_path(config('koel.album_cover_dir') . $fileName);
|
||||
return $fileName ? static_url(config('koel.album_cover_dir') . $fileName) : null;
|
||||
}
|
||||
|
||||
function album_cover_url(string $fileName): string
|
||||
function artist_image_path(?string $fileName): ?string
|
||||
{
|
||||
return static_url(config('koel.album_cover_dir') . $fileName);
|
||||
return $fileName ? public_path(config('koel.artist_image_dir') . $fileName) : null;
|
||||
}
|
||||
|
||||
function artist_image_path(string $fileName): string
|
||||
function artist_image_url(?string $fileName): ?string
|
||||
{
|
||||
return public_path(config('koel.artist_image_dir') . $fileName);
|
||||
}
|
||||
|
||||
function artist_image_url(string $fileName): string
|
||||
{
|
||||
return static_url(config('koel.artist_image_dir') . $fileName);
|
||||
return $fileName ? static_url(config('koel.artist_image_dir') . $fileName) : null;
|
||||
}
|
||||
|
||||
function koel_version(): string
|
||||
|
|
|
@ -11,7 +11,6 @@ use App\Repositories\SongRepository;
|
|||
use App\Services\ApplicationInformationService;
|
||||
use App\Services\ITunesService;
|
||||
use App\Services\LastfmService;
|
||||
use App\Services\SpotifyService;
|
||||
use App\Services\YouTubeService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
|
@ -37,7 +36,6 @@ class DataController extends Controller
|
|||
'playlists' => $this->playlistRepository->getAllByCurrentUser(),
|
||||
'current_user' => UserResource::make($this->user),
|
||||
'use_last_fm' => $this->lastfmService->used(),
|
||||
'use_spotify' => SpotifyService::enabled(),
|
||||
'use_you_tube' => $this->youTubeService->enabled(), // @todo clean this mess up
|
||||
'use_i_tunes' => $this->iTunesService->used(),
|
||||
'allow_download' => config('koel.download.allow'),
|
||||
|
|
|
@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
|
@ -76,27 +77,23 @@ class Album extends Model
|
|||
|
||||
protected function isUnknown(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->id === self::UNKNOWN_ID);
|
||||
return Attribute::get(fn (): bool => $this->id === self::UNKNOWN_ID);
|
||||
}
|
||||
|
||||
protected function cover(): Attribute
|
||||
{
|
||||
return Attribute::get(static fn (?string $value) => $value ? album_cover_url($value) : '');
|
||||
return Attribute::get(static fn (?string $value): ?string => album_cover_url($value));
|
||||
}
|
||||
|
||||
protected function hasCover(): Attribute
|
||||
{
|
||||
return Attribute::get(function () {
|
||||
$cover = array_get($this->attributes, 'cover');
|
||||
|
||||
return $cover && file_exists(album_cover_path($cover));
|
||||
});
|
||||
return Attribute::get(fn (): bool => $this->cover_path && file_exists($this->cover_path));
|
||||
}
|
||||
|
||||
protected function coverPath(): Attribute
|
||||
{
|
||||
return Attribute::get(function () {
|
||||
$cover = array_get($this->attributes, 'cover');
|
||||
$cover = Arr::get($this->attributes, 'cover');
|
||||
|
||||
return $cover ? album_cover_path($cover) : null;
|
||||
});
|
||||
|
|
|
@ -5,11 +5,13 @@ namespace App\Models;
|
|||
use App\Facades\Util;
|
||||
use Illuminate\Contracts\Database\Query\Builder as BuilderContract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
|
@ -45,11 +47,6 @@ class Artist extends Model
|
|||
protected $guarded = ['id'];
|
||||
protected $hidden = ['created_at', 'updated_at'];
|
||||
|
||||
public static function getVariousArtist(): self
|
||||
{
|
||||
return static::find(self::VARIOUS_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an Artist object from their name.
|
||||
* If such is not found, a new artist will be created.
|
||||
|
@ -76,51 +73,45 @@ class Artist extends Model
|
|||
return $this->hasMany(Song::class);
|
||||
}
|
||||
|
||||
public function getIsUnknownAttribute(): bool
|
||||
protected function isUnknown(): Attribute
|
||||
{
|
||||
return $this->id === self::UNKNOWN_ID;
|
||||
return Attribute::get(fn (): bool => $this->id === self::UNKNOWN_ID);
|
||||
}
|
||||
|
||||
public function getIsVariousAttribute(): bool
|
||||
protected function isVarious(): Attribute
|
||||
{
|
||||
return $this->id === self::VARIOUS_ID;
|
||||
return Attribute::get(fn (): bool => $this->id === self::VARIOUS_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sometimes the tags extracted from getID3 are HTML entity encoded.
|
||||
* This makes sure they are always sane.
|
||||
*/
|
||||
public function getNameAttribute(string $value): string
|
||||
protected function name(): Attribute
|
||||
{
|
||||
return html_entity_decode($value ?: self::UNKNOWN_NAME);
|
||||
return Attribute::get(static fn (string $value): string => html_entity_decode($value) ?: self::UNKNOWN_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the image name into its absolute URL.
|
||||
*/
|
||||
public function getImageAttribute(?string $value): ?string
|
||||
protected function image(): Attribute
|
||||
{
|
||||
return $value ? artist_image_url($value) : null;
|
||||
return Attribute::get(static fn (?string $value): ?string => artist_image_url($value));
|
||||
}
|
||||
|
||||
public function getImagePathAttribute(): ?string
|
||||
protected function imagePath(): Attribute
|
||||
{
|
||||
if (!$this->has_image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return artist_image_path(array_get($this->attributes, 'image'));
|
||||
return Attribute::get(fn (): ?string => artist_image_path(Arr::get($this->attributes, 'image')));
|
||||
}
|
||||
|
||||
public function getHasImageAttribute(): bool
|
||||
protected function hasImage(): Attribute
|
||||
{
|
||||
$image = array_get($this->attributes, 'image');
|
||||
return Attribute::get(function (): bool {
|
||||
$image = Arr::get($this->attributes, 'image');
|
||||
|
||||
if (!$image) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return file_exists(artist_image_path($image));
|
||||
return $image && file_exists(artist_image_path($image));
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeIsStandard(Builder $query): Builder
|
||||
|
|
|
@ -5,6 +5,7 @@ namespace App\Models;
|
|||
use App\Casts\SmartPlaylistRulesCast;
|
||||
use App\Values\SmartPlaylistRuleGroup;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
@ -16,7 +17,8 @@ use Laravel\Scout\Searchable;
|
|||
* @property int $user_id
|
||||
* @property Collection|array $songs
|
||||
* @property int $id
|
||||
* @property Collection|array<SmartPlaylistRuleGroup> $rule_groups
|
||||
* @property Collection|array<array-key, SmartPlaylistRuleGroup> $rule_groups
|
||||
* @property Collection|array<array-key, SmartPlaylistRuleGroup> $rules
|
||||
* @property bool $is_smart
|
||||
* @property string $name
|
||||
* @property user $user
|
||||
|
@ -48,15 +50,16 @@ class Playlist extends Model
|
|||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function getIsSmartAttribute(): bool
|
||||
protected function isSmart(): Attribute
|
||||
{
|
||||
return $this->rule_groups->isNotEmpty();
|
||||
return Attribute::get(fn (): bool => $this->rule_groups->isNotEmpty());
|
||||
}
|
||||
|
||||
/** @return Collection|array<SmartPlaylistRuleGroup> */
|
||||
public function getRuleGroupsAttribute(): Collection
|
||||
/** @return Collection|array<array-key, SmartPlaylistRuleGroup> */
|
||||
protected function ruleGroups(): Attribute
|
||||
{
|
||||
return $this->rules;
|
||||
// aliasing the attribute to avoid confusion
|
||||
return Attribute::get(fn () => $this->rules);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
|
|
|
@ -3,9 +3,10 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
||||
/**
|
||||
* @property array<string> $s3_params
|
||||
* @property array<string>|null $s3_params
|
||||
*
|
||||
* @method static Builder hostedOnS3()
|
||||
*/
|
||||
|
@ -16,15 +17,17 @@ trait SupportsS3
|
|||
*
|
||||
* @return array<string>|null
|
||||
*/
|
||||
public function getS3ParamsAttribute(): ?array
|
||||
protected function s3Params(): Attribute
|
||||
{
|
||||
if (!preg_match('/^s3:\\/\\/(.*)/', $this->path, $matches)) {
|
||||
return null;
|
||||
}
|
||||
return Attribute::get(function (): ?array {
|
||||
if (!preg_match('/^s3:\\/\\/(.*)/', $this->path, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$bucket, $key] = explode('/', $matches[1], 2);
|
||||
[$bucket, $key] = explode('/', $matches[1], 2);
|
||||
|
||||
return compact('bucket', 'key');
|
||||
return compact('bucket', 'key');
|
||||
});
|
||||
}
|
||||
|
||||
public static function getPathFromS3BucketAndKey(string $bucket, string $key): string
|
||||
|
|
|
@ -59,6 +59,16 @@ class User extends Authenticatable
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's Last.fm session key.
|
||||
*
|
||||
* @return string|null The key if found, or null if user isn't connected to Last.fm
|
||||
*/
|
||||
protected function lastfmSessionKey(): Attribute
|
||||
{
|
||||
return Attribute::get(fn (): ?string => $this->preferences->lastFmSessionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user is connected to Last.fm.
|
||||
*/
|
||||
|
@ -66,14 +76,4 @@ class User extends Authenticatable
|
|||
{
|
||||
return (bool) $this->lastfm_session_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's Last.fm session key.
|
||||
*
|
||||
* @return string|null The key if found, or null if user isn't connected to Last.fm
|
||||
*/
|
||||
public function getLastfmSessionKeyAttribute(): ?string
|
||||
{
|
||||
return $this->preferences->lastFmSessionKey;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\SpotifyService;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
use Illuminate\Database\SQLiteConnection;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Factory as Validator;
|
||||
use SpotifyWebAPI\Session as SpotifySession;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
@ -29,6 +31,12 @@ class AppServiceProvider extends ServiceProvider
|
|||
|
||||
// disable wrapping JSON resource in a `data` key
|
||||
JsonResource::withoutWrapping();
|
||||
|
||||
$this->app->bind(SpotifySession::class, static function () {
|
||||
return SpotifyService::enabled()
|
||||
? new SpotifySession(config('koel.spotify.client_id'), config('koel.spotify.client_secret'))
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -12,7 +12,6 @@ class MediaInformationService
|
|||
{
|
||||
public function __construct(
|
||||
private LastfmService $lastfmService,
|
||||
private SpotifyService $spotifyService,
|
||||
private MediaMetadataService $mediaMetadataService
|
||||
) {
|
||||
}
|
||||
|
@ -23,16 +22,12 @@ class MediaInformationService
|
|||
return null;
|
||||
}
|
||||
|
||||
$info = $this->lastfmService->getAlbumInformation($album) ?: new AlbumInformation();
|
||||
$info = $this->lastfmService->getAlbumInformation($album) ?: AlbumInformation::make();
|
||||
|
||||
if (!$album->has_cover) {
|
||||
try {
|
||||
$cover = $this->spotifyService->tryGetAlbumCover($album);
|
||||
|
||||
if ($cover) {
|
||||
$this->mediaMetadataService->downloadAlbumCover($album, $cover);
|
||||
$info->cover = $album->refresh()->cover;
|
||||
}
|
||||
$this->mediaMetadataService->tryDownloadAlbumCover($album);
|
||||
$info->cover = $album->cover;
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
@ -46,16 +41,12 @@ class MediaInformationService
|
|||
return null;
|
||||
}
|
||||
|
||||
$info = $this->lastfmService->getArtistInformation($artist) ?: new ArtistInformation();
|
||||
$info = $this->lastfmService->getArtistInformation($artist) ?: ArtistInformation::make();
|
||||
|
||||
if (!$artist->has_image) {
|
||||
try {
|
||||
$image = $this->spotifyService->tryGetArtistImage($artist);
|
||||
|
||||
if ($image) {
|
||||
$this->mediaMetadataService->downloadArtistImage($artist, $image);
|
||||
$info->image = $artist->refresh()->image;
|
||||
}
|
||||
$this->mediaMetadataService->tryDownloadArtistImage($artist);
|
||||
$info->image = $artist->image;
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,13 +10,18 @@ use Throwable;
|
|||
|
||||
class MediaMetadataService
|
||||
{
|
||||
public function __construct(private ImageWriter $imageWriter, private LoggerInterface $logger)
|
||||
{
|
||||
public function __construct(
|
||||
private SpotifyService $spotifyService,
|
||||
private ImageWriter $imageWriter,
|
||||
private LoggerInterface $logger
|
||||
) {
|
||||
}
|
||||
|
||||
public function downloadAlbumCover(Album $album, string $imageUrl): void
|
||||
public function tryDownloadAlbumCover(Album $album): void
|
||||
{
|
||||
$this->writeAlbumCover($album, $imageUrl);
|
||||
optional($this->spotifyService->tryGetAlbumCover($album), function (string $coverUrl) use ($album): void {
|
||||
$this->writeAlbumCover($album, $coverUrl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -29,7 +34,7 @@ class MediaMetadataService
|
|||
Album $album,
|
||||
string $source,
|
||||
string $extension = 'png',
|
||||
string $destination = '',
|
||||
?string $destination = '',
|
||||
bool $cleanUp = true
|
||||
): void {
|
||||
try {
|
||||
|
@ -48,9 +53,11 @@ class MediaMetadataService
|
|||
}
|
||||
}
|
||||
|
||||
public function downloadArtistImage(Artist $artist, string $imageUrl): void
|
||||
public function tryDownloadArtistImage(Artist $artist): void
|
||||
{
|
||||
$this->writeArtistImage($artist, $imageUrl);
|
||||
optional($this->spotifyService->tryGetArtistImage($artist), function (string $imageUrl) use ($artist): void {
|
||||
$this->writeArtistImage($artist, $imageUrl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
55
app/Services/SpotifyClient.php
Normal file
55
app/Services/SpotifyClient.php
Normal file
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\SpotifyIntegrationDisabledException;
|
||||
use Illuminate\Cache\Repository as Cache;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use SpotifyWebAPI\Session;
|
||||
use SpotifyWebAPI\SpotifyWebAPI;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @method array search(string $keywords, string|array $type, string|object $options = [])
|
||||
*/
|
||||
class SpotifyClient
|
||||
{
|
||||
public function __construct(
|
||||
public SpotifyWebAPI $wrapped,
|
||||
private ?Session $session,
|
||||
private Cache $cache,
|
||||
private LoggerInterface $log
|
||||
) {
|
||||
if (SpotifyService::enabled()) {
|
||||
$this->wrapped->setOptions(['return_assoc' => true]);
|
||||
|
||||
try {
|
||||
$this->setAccessToken();
|
||||
} catch (Throwable $e) {
|
||||
$this->log->error('Failed to set Spotify access token', ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function setAccessToken(): void
|
||||
{
|
||||
$token = $this->cache->get('spotify.access_token');
|
||||
|
||||
if (!$token) {
|
||||
$this->session->requestCredentialsToken();
|
||||
$token = $this->session->getAccessToken();
|
||||
|
||||
// Spotify's tokens expire after 1 hour, so we'll cache them with some buffer to an extra call.
|
||||
$this->cache->put('spotify.access_token', $token, 59 * 60);
|
||||
}
|
||||
|
||||
$this->wrapped->setAccessToken($token);
|
||||
}
|
||||
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
{
|
||||
throw_unless(SpotifyService::enabled(), SpotifyIntegrationDisabledException::create());
|
||||
|
||||
return $this->wrapped->$name(...$arguments);
|
||||
}
|
||||
}
|
|
@ -4,24 +4,12 @@ namespace App\Services;
|
|||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Cache\Repository as Cache;
|
||||
use Illuminate\Support\Arr;
|
||||
use LogicException;
|
||||
use SpotifyWebAPI\Session as SpotifySession;
|
||||
use SpotifyWebAPI\SpotifyWebAPI;
|
||||
|
||||
class SpotifyService
|
||||
{
|
||||
private ?SpotifySession $session;
|
||||
|
||||
public function __construct(private SpotifyWebAPI $client, private Cache $cache)
|
||||
public function __construct(private SpotifyClient $client)
|
||||
{
|
||||
if (static::enabled()) {
|
||||
$this->session = new SpotifySession(config('koel.spotify.client_id'), config('koel.spotify.client_secret'));
|
||||
$this->client->setOptions(['return_assoc' => true]);
|
||||
$this->client->setAccessToken($this->getAccessToken());
|
||||
}
|
||||
}
|
||||
|
||||
public static function enabled(): bool
|
||||
|
@ -29,27 +17,16 @@ class SpotifyService
|
|||
return config('koel.spotify.client_id') && config('koel.spotify.client_secret');
|
||||
}
|
||||
|
||||
private function getAccessToken(): string
|
||||
{
|
||||
if (!$this->session) {
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
if (!$this->cache->has('spotify.access_token')) {
|
||||
$this->session->requestCredentialsToken();
|
||||
$token = $this->session->getAccessToken();
|
||||
$this->cache->put('spotify.access_token', $token, Carbon::now()->addMinutes(59));
|
||||
}
|
||||
|
||||
return $this->cache->get('spotify.access_token');
|
||||
}
|
||||
|
||||
public function tryGetArtistImage(Artist $artist): ?string
|
||||
{
|
||||
if (!static::enabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($artist->is_various || $artist->is_unknown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Arr::get(
|
||||
$this->client->search($artist->name, 'artist', ['limit' => 1]),
|
||||
'artists.items.0.images.0.url'
|
||||
|
@ -66,12 +43,8 @@ class SpotifyService
|
|||
return null;
|
||||
}
|
||||
|
||||
if ($album->name === Album::UNKNOWN_NAME) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Arr::get(
|
||||
$this->client->search("{$album->artist->name} {$album->name}", 'album', ['limit' => 1]),
|
||||
$this->client->search("{$album->name} artist:{$album->artist->name}", 'album', ['limit' => 1]),
|
||||
'albums.items.0.images.0.url'
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,17 +8,22 @@ final class AlbumInformation implements Arrayable
|
|||
{
|
||||
use FormatsLastFmText;
|
||||
|
||||
public function __construct(
|
||||
public ?string $url = null,
|
||||
public ?string $cover = null,
|
||||
public array $wiki = ['summary' => '', 'full' => ''],
|
||||
public array $tracks = []
|
||||
) {
|
||||
private function __construct(public ?string $url, public ?string $cover, public array $wiki, public array $tracks)
|
||||
{
|
||||
}
|
||||
|
||||
public static function make(
|
||||
?string $url = null,
|
||||
?string $cover = null,
|
||||
array $wiki = ['summary' => '', 'full' => ''],
|
||||
array $tracks = []
|
||||
): self {
|
||||
return new self($url, $cover, $wiki, $tracks);
|
||||
}
|
||||
|
||||
public static function fromLastFmData(object $data): self
|
||||
{
|
||||
return new self(
|
||||
return self::make(
|
||||
url: $data->url,
|
||||
wiki: [
|
||||
'summary' => isset($data->wiki) ? self::formatLastFmText($data->wiki->summary) : '',
|
||||
|
|
|
@ -8,16 +8,21 @@ final class ArtistInformation implements Arrayable
|
|||
{
|
||||
use FormatsLastFmText;
|
||||
|
||||
public function __construct(
|
||||
public ?string $url = null,
|
||||
public ?string $image = null,
|
||||
public array $bio = ['summary' => '', 'full' => '']
|
||||
) {
|
||||
private function __construct(public ?string $url, public ?string $image, public array $bio)
|
||||
{
|
||||
}
|
||||
|
||||
public static function make(
|
||||
?string $url = null,
|
||||
?string $image = null,
|
||||
array $bio = ['summary' => '', 'full' => '']
|
||||
): self {
|
||||
return new self($url, $image, $bio);
|
||||
}
|
||||
|
||||
public static function fromLastFmData(object $data): self
|
||||
{
|
||||
return new self(
|
||||
return self::make(
|
||||
url: $data->url,
|
||||
bio: [
|
||||
'summary' => isset($data->bio) ? self::formatLastFmText($data->bio->summary) : '',
|
||||
|
|
|
@ -29,12 +29,12 @@ class LastfmServiceTest extends TestCase
|
|||
|
||||
self::assertEquals([
|
||||
'url' => 'https://www.last.fm/music/Kamelot',
|
||||
'image' => 'http://foo.bar/extralarge.jpg',
|
||||
'image' => null,
|
||||
'bio' => [
|
||||
'summary' => 'Quisque ut nisi.',
|
||||
'full' => 'Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.',
|
||||
],
|
||||
], $info);
|
||||
], $info->toArray());
|
||||
|
||||
self::assertNotNull(cache()->get('0aff3bc1259154f0e9db860026cda7a6'));
|
||||
}
|
||||
|
@ -75,7 +75,7 @@ class LastfmServiceTest extends TestCase
|
|||
|
||||
self::assertEquals([
|
||||
'url' => 'https://www.last.fm/music/Kamelot/Epica',
|
||||
'image' => 'http://foo.bar/extralarge.jpg',
|
||||
'cover' => null,
|
||||
'tracks' => [
|
||||
[
|
||||
'title' => 'Track 1',
|
||||
|
@ -92,7 +92,7 @@ class LastfmServiceTest extends TestCase
|
|||
'summary' => 'Quisque ut nisi.',
|
||||
'full' => 'Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.',
|
||||
],
|
||||
], $info);
|
||||
], $info->toArray());
|
||||
|
||||
self::assertNotNull(cache()->get('fca889d13b3222589d7d020669cc5a38'));
|
||||
}
|
||||
|
|
|
@ -2,14 +2,13 @@
|
|||
|
||||
namespace Tests\Integration\Services;
|
||||
|
||||
use App\Events\AlbumInformationFetched;
|
||||
use App\Events\ArtistInformationFetched;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Repositories\ArtistRepository;
|
||||
use App\Services\LastfmService;
|
||||
use App\Services\MediaInformationService;
|
||||
use App\Services\MediaMetadataService;
|
||||
use App\Values\AlbumInformation;
|
||||
use App\Values\ArtistInformation;
|
||||
use Mockery;
|
||||
use Mockery\LegacyMockInterface;
|
||||
use Mockery\MockInterface;
|
||||
|
@ -18,6 +17,7 @@ use Tests\TestCase;
|
|||
class MediaInformationServiceTest extends TestCase
|
||||
{
|
||||
private LastfmService|MockInterface|LegacyMockInterface $lastFmService;
|
||||
private MediaMetadataService|LegacyMockInterface|MockInterface $mediaMetadataService;
|
||||
private MediaInformationService $mediaInformationService;
|
||||
|
||||
public function setUp(): void
|
||||
|
@ -25,55 +25,76 @@ class MediaInformationServiceTest extends TestCase
|
|||
parent::setUp();
|
||||
|
||||
$this->lastFmService = Mockery::mock(LastfmService::class);
|
||||
$this->albumRepository = Mockery::mock(AlbumRepository::class);
|
||||
$this->artistRepository = Mockery::mock(ArtistRepository::class);
|
||||
$this->mediaMetadataService = Mockery::mock(MediaMetadataService::class);
|
||||
|
||||
$this->mediaInformationService = new MediaInformationService(
|
||||
$this->lastFmService,
|
||||
app(AlbumRepository::class),
|
||||
app(ArtistRepository::class)
|
||||
);
|
||||
$this->mediaInformationService = new MediaInformationService($this->lastFmService, $this->mediaMetadataService);
|
||||
}
|
||||
|
||||
public function testGetAlbumInformation(): void
|
||||
{
|
||||
$this->expectsEvents(AlbumInformationFetched::class);
|
||||
|
||||
/** @var Album $album */
|
||||
$album = Album::factory()->create();
|
||||
$info = AlbumInformation::make();
|
||||
|
||||
$this->lastFmService
|
||||
->shouldReceive('getAlbumInformation')
|
||||
->once()
|
||||
->with($album->name, $album->artist->name)
|
||||
->andReturn(['foo' => 'bar']);
|
||||
->with($album)
|
||||
->andReturn($info);
|
||||
|
||||
$info = $this->mediaInformationService->getAlbumInformation($album);
|
||||
self::assertSame($info, $this->mediaInformationService->getAlbumInformation($album));
|
||||
}
|
||||
|
||||
self::assertEquals([
|
||||
'foo' => 'bar',
|
||||
'cover' => $album->cover,
|
||||
], $info);
|
||||
public function testGetAlbumInformationTriesDownloadingCover(): void
|
||||
{
|
||||
/** @var Album $album */
|
||||
$album = Album::factory()->create(['cover' => '']);
|
||||
$info = AlbumInformation::make();
|
||||
|
||||
$this->lastFmService
|
||||
->shouldReceive('getAlbumInformation')
|
||||
->once()
|
||||
->with($album)
|
||||
->andReturn($info);
|
||||
|
||||
$this->mediaMetadataService
|
||||
->shouldReceive('tryDownloadAlbumCover')
|
||||
->with($album);
|
||||
|
||||
self::assertSame($info, $this->mediaInformationService->getAlbumInformation($album));
|
||||
}
|
||||
|
||||
public function testGetArtistInformation(): void
|
||||
{
|
||||
$this->expectsEvents(ArtistInformationFetched::class);
|
||||
|
||||
/** @var Artist $artist */
|
||||
$artist = Artist::factory()->create();
|
||||
$info = ArtistInformation::make();
|
||||
|
||||
$this->lastFmService
|
||||
->shouldReceive('getArtistInformation')
|
||||
->once()
|
||||
->with($artist->name)
|
||||
->andReturn(['foo' => 'bar']);
|
||||
->with($artist)
|
||||
->andReturn($info);
|
||||
|
||||
$info = $this->mediaInformationService->getArtistInformation($artist);
|
||||
self::assertSame($info, $this->mediaInformationService->getArtistInformation($artist));
|
||||
}
|
||||
|
||||
self::assertEquals([
|
||||
'foo' => 'bar',
|
||||
'image' => $artist->image,
|
||||
], $info);
|
||||
public function testGetArtistInformationTriesDownloadingImage(): void
|
||||
{
|
||||
/** @var Artist $artist */
|
||||
$artist = Artist::factory()->create(['image' => '']);
|
||||
$info = ArtistInformation::make();
|
||||
|
||||
$this->lastFmService
|
||||
->shouldReceive('getArtistInformation')
|
||||
->once()
|
||||
->with($artist)
|
||||
->andReturn($info);
|
||||
|
||||
$this->mediaMetadataService
|
||||
->shouldReceive('tryDownloadArtistImage')
|
||||
->with($artist);
|
||||
|
||||
self::assertSame($info, $this->mediaInformationService->getArtistInformation($artist));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ class PlaylistServiceTest extends TestCase
|
|||
$user = User::factory()->create();
|
||||
|
||||
$playlist = $this->service->createPlaylist('foo', $user, []);
|
||||
self::assertFalse($playlist->getIsSmartAttribute());
|
||||
self::assertFalse($playlist->is_smart);
|
||||
}
|
||||
|
||||
public function testCreateSmartPlaylist(): void
|
||||
|
@ -41,6 +41,6 @@ class PlaylistServiceTest extends TestCase
|
|||
$user = User::factory()->create();
|
||||
|
||||
$playlist = $this->service->createPlaylist('foo', $user, [], $rules);
|
||||
self::assertTrue($playlist->getIsSmartAttribute());
|
||||
self::assertTrue($playlist->is_smart);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,13 +6,6 @@ use Tests\TestCase;
|
|||
|
||||
class ApplicationTest extends TestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
@unlink(public_path('hot'));
|
||||
}
|
||||
|
||||
public function testStaticUrlsWithoutCdnAreConstructedCorrectly(): void
|
||||
{
|
||||
config(['koel.cdn.url' => '']);
|
||||
|
@ -28,20 +21,4 @@ class ApplicationTest extends TestCase
|
|||
self::assertEquals('http://cdn.tld/', static_url());
|
||||
self::assertEquals('http://cdn.tld/foo.css', static_url('/foo.css '));
|
||||
}
|
||||
|
||||
public function testApplicationAssetRevisionUrlsAreConstructedCorrectlyWhenNotUsingCdn(): void
|
||||
{
|
||||
$manifestFile = __DIR__ . '../../blobs/rev-manifest.json';
|
||||
config(['koel.cdn.url' => '']);
|
||||
|
||||
self::assertEquals('http://localhost/foo00.css', asset_rev('/foo.css', $manifestFile));
|
||||
}
|
||||
|
||||
public function testApplicationAssetRevisionUrlsAreConstructedCorrectlyWhenUsingCdn(): void
|
||||
{
|
||||
$manifestFile = __DIR__ . '../../blobs/rev-manifest.json';
|
||||
config(['koel.cdn.url' => 'http://cdn.tld']);
|
||||
|
||||
self::assertEquals('http://cdn.tld/foo00.css', asset_rev('/foo.css', $manifestFile));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,54 +6,86 @@ use App\Models\Album;
|
|||
use App\Models\Artist;
|
||||
use App\Services\ImageWriter;
|
||||
use App\Services\MediaMetadataService;
|
||||
use Illuminate\Log\Logger;
|
||||
use App\Services\SpotifyService;
|
||||
use Mockery;
|
||||
use Mockery\LegacyMockInterface;
|
||||
use Mockery\MockInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MediaMetadataServiceTest extends TestCase
|
||||
{
|
||||
private MediaMetadataService $mediaMetadataService;
|
||||
private LegacyMockInterface|SpotifyService|MockInterface $spotifyService;
|
||||
private LegacyMockInterface|ImageWriter|MockInterface $imageWriter;
|
||||
private MediaMetadataService $mediaMetadataService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->spotifyService = Mockery::mock(SpotifyService::class);
|
||||
$this->imageWriter = Mockery::mock(ImageWriter::class);
|
||||
$this->mediaMetadataService = new MediaMetadataService($this->imageWriter, app(Logger::class));
|
||||
|
||||
$this->mediaMetadataService = new MediaMetadataService(
|
||||
$this->spotifyService,
|
||||
$this->imageWriter,
|
||||
app(LoggerInterface::class)
|
||||
);
|
||||
}
|
||||
|
||||
public function testTryDownloadAlbumCover(): void
|
||||
{
|
||||
/** @var Album $album */
|
||||
$album = Album::factory()->create(['cover' => '']);
|
||||
|
||||
$this->spotifyService
|
||||
->shouldReceive('tryGetAlbumCover')
|
||||
->with($album)
|
||||
->andReturn('/dev/null/cover.jpg');
|
||||
|
||||
$this->mediaMetadataService->tryDownloadAlbumCover($album);
|
||||
}
|
||||
|
||||
public function testWriteAlbumCover(): void
|
||||
{
|
||||
/** @var Album $album */
|
||||
$album = Album::factory()->create();
|
||||
$coverContent = 'dummy';
|
||||
$coverPath = '/koel/public/img/album/foo.jpg';
|
||||
|
||||
$this->imageWriter
|
||||
->shouldReceive('writeFromBinaryData')
|
||||
->shouldReceive('write')
|
||||
->once()
|
||||
->with('/koel/public/img/album/foo.jpg', 'dummy');
|
||||
->with('/koel/public/img/album/foo.jpg', 'dummy-src');
|
||||
|
||||
$this->mediaMetadataService->writeAlbumCover($album, $coverContent, 'jpg', $coverPath);
|
||||
$this->mediaMetadataService->writeAlbumCover($album, 'dummy-src', 'jpg', $coverPath);
|
||||
self::assertEquals(album_cover_url('foo.jpg'), Album::find($album->id)->cover);
|
||||
}
|
||||
|
||||
public function testTryDownloadArtistImage(): void
|
||||
{
|
||||
/** @var Artist $artist */
|
||||
$artist = Artist::factory()->create(['image' => '']);
|
||||
|
||||
$this->spotifyService
|
||||
->shouldReceive('tryGetArtistImage')
|
||||
->with($artist)
|
||||
->andReturn('/dev/null/img.jpg');
|
||||
|
||||
$this->mediaMetadataService->tryDownloadArtistImage($artist);
|
||||
}
|
||||
|
||||
public function testWriteArtistImage(): void
|
||||
{
|
||||
/** @var Artist $artist */
|
||||
$artist = Artist::factory()->create();
|
||||
$imageContent = 'dummy';
|
||||
$imagePath = '/koel/public/img/artist/foo.jpg';
|
||||
|
||||
$this->imageWriter
|
||||
->shouldReceive('writeFromBinaryData')
|
||||
->shouldReceive('write')
|
||||
->once()
|
||||
->with('/koel/public/img/artist/foo.jpg', 'dummy');
|
||||
->with('/koel/public/img/artist/foo.jpg', 'dummy-src');
|
||||
|
||||
$this->mediaMetadataService->writeArtistImage($artist, $imageContent, 'jpg', $imagePath);
|
||||
$this->mediaMetadataService->writeArtistImage($artist, 'dummy-src', 'jpg', $imagePath);
|
||||
self::assertEquals(artist_image_url('foo.jpg'), Artist::find($artist->id)->image);
|
||||
}
|
||||
}
|
||||
|
|
99
tests/Unit/Services/SpotifyClientTest.php
Normal file
99
tests/Unit/Services/SpotifyClientTest.php
Normal file
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Exceptions\SpotifyIntegrationDisabledException;
|
||||
use App\Services\SpotifyClient;
|
||||
use Illuminate\Cache\Repository as Cache;
|
||||
use Mockery;
|
||||
use Mockery\LegacyMockInterface;
|
||||
use Mockery\MockInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use SpotifyWebAPI\Session as SpotifySession;
|
||||
use SpotifyWebAPI\SpotifyWebAPI;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SpotifyClientTest extends TestCase
|
||||
{
|
||||
private SpotifySession|LegacyMockInterface|MockInterface $session;
|
||||
private SpotifyWebAPI|LegacyMockInterface|MockInterface $wrapped;
|
||||
private Cache|LegacyMockInterface|MockInterface $cache;
|
||||
private LoggerInterface|LegacyMockInterface|MockInterface $logger;
|
||||
|
||||
private SpotifyClient $client;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'koel.spotify.client_id' => 'fake-client-id',
|
||||
'koel.spotify.client_secret' => 'fake-client-secret',
|
||||
]);
|
||||
|
||||
$this->session = Mockery::mock(SpotifySession::class);
|
||||
$this->wrapped = Mockery::mock(SpotifyWebAPI::class);
|
||||
$this->cache = Mockery::mock(Cache::class);
|
||||
$this->logger = Mockery::mock(LoggerInterface::class);
|
||||
}
|
||||
|
||||
public function testAccessTokenIsSetUponInitialization(): void
|
||||
{
|
||||
$this->mockSetAccessToken();
|
||||
|
||||
$this->client = new SpotifyClient($this->wrapped, $this->session, $this->cache, $this->logger);
|
||||
}
|
||||
|
||||
public function testAccessTokenIsRetrievedFromCacheWhenApplicable(): void
|
||||
{
|
||||
$this->wrapped->shouldReceive('setOptions')->with(['return_assoc' => true]);
|
||||
$this->cache->shouldReceive('get')->with('spotify.access_token')->andReturn('fake-access-token');
|
||||
$this->session->shouldNotReceive('requestCredentialsToken');
|
||||
$this->session->shouldNotReceive('getAccessToken');
|
||||
$this->cache->shouldNotReceive('put');
|
||||
$this->wrapped->shouldReceive('setAccessToken')->with('fake-access-token');
|
||||
|
||||
$this->client = new SpotifyClient($this->wrapped, $this->session, $this->cache, $this->logger);
|
||||
}
|
||||
|
||||
public function testCallForwarding(): void
|
||||
{
|
||||
$this->mockSetAccessToken();
|
||||
$this->wrapped->shouldReceive('search')->with('foo', 'track')->andReturn('bar');
|
||||
|
||||
$this->client = new SpotifyClient($this->wrapped, $this->session, $this->cache, $this->logger);
|
||||
|
||||
self::assertSame('bar', $this->client->search('foo', 'track'));
|
||||
}
|
||||
|
||||
public function testCallForwardingThrowsIfIntegrationIsDisabled(): void
|
||||
{
|
||||
config([
|
||||
'koel.spotify.client_id' => null,
|
||||
'koel.spotify.client_secret' => null,
|
||||
]);
|
||||
|
||||
self::expectException(SpotifyIntegrationDisabledException::class);
|
||||
(new SpotifyClient($this->wrapped, $this->session, $this->cache, $this->logger))->search('foo', 'track');
|
||||
}
|
||||
|
||||
private function mockSetAccessToken(): void
|
||||
{
|
||||
$this->wrapped->shouldReceive('setOptions')->with(['return_assoc' => true]);
|
||||
$this->cache->shouldReceive('get')->with('spotify.access_token')->andReturnNull();
|
||||
$this->session->shouldReceive('requestCredentialsToken');
|
||||
$this->session->shouldReceive('getAccessToken')->andReturn('fake-access-token');
|
||||
$this->cache->shouldReceive('put')->with('spotify.access_token', 'fake-access-token', 3_540);
|
||||
$this->wrapped->shouldReceive('setAccessToken')->with('fake-access-token');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
config([
|
||||
'koel.spotify.client_id' => null,
|
||||
'koel.spotify.client_secret' => null,
|
||||
]);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
94
tests/Unit/Services/SpotifyServiceTest.php
Normal file
94
tests/Unit/Services/SpotifyServiceTest.php
Normal file
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Services\SpotifyClient;
|
||||
use App\Services\SpotifyService;
|
||||
use Mockery;
|
||||
use Mockery\LegacyMockInterface;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SpotifyServiceTest extends TestCase
|
||||
{
|
||||
private SpotifyService $service;
|
||||
private SpotifyClient|LegacyMockInterface|MockInterface $client;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'koel.spotify.client_id' => 'fake-client-id',
|
||||
'koel.spotify.client_secret' => 'fake-client-secret',
|
||||
]);
|
||||
|
||||
$this->client = Mockery::mock(SpotifyClient::class);
|
||||
$this->service = new SpotifyService($this->client);
|
||||
}
|
||||
|
||||
public function testTryGetArtistImage(): void
|
||||
{
|
||||
/** @var Artist $artist */
|
||||
$artist = Artist::factory(['name' => 'Foo'])->create();
|
||||
|
||||
$this->client
|
||||
->shouldReceive('search')
|
||||
->with('Foo', 'artist', ['limit' => 1])
|
||||
->andReturn(self::parseFixture('search-artist.json'));
|
||||
|
||||
self::assertSame('https://foo/bar.jpg', $this->service->tryGetArtistImage($artist));
|
||||
}
|
||||
|
||||
public function testTryGetArtistImageWhenServiceIsNotEnabled(): void
|
||||
{
|
||||
config(['koel.spotify.client_id' => null]);
|
||||
|
||||
$this->client->shouldNotReceive('search');
|
||||
|
||||
self::assertNull($this->service->tryGetArtistImage(Artist::factory()->create()));
|
||||
}
|
||||
|
||||
public function testTryGetAlbumImage(): void
|
||||
{
|
||||
/** @var Artist $artist */
|
||||
$artist = Artist::factory(['name' => 'Foo'])->create();
|
||||
|
||||
/** @var Album $album */
|
||||
$album = Album::factory(['name' => 'Bar', 'artist_id' => $artist->id])->create();
|
||||
|
||||
$this->client
|
||||
->shouldReceive('search')
|
||||
->with('Bar artist:Foo', 'album', ['limit' => 1])
|
||||
->andReturn(self::parseFixture('search-album.json'));
|
||||
|
||||
self::assertSame('https://foo/bar.jpg', $this->service->tryGetAlbumCover($album));
|
||||
}
|
||||
|
||||
public function testTryGetAlbumImageWhenServiceIsNotEnabled(): void
|
||||
{
|
||||
config(['koel.spotify.client_id' => null]);
|
||||
|
||||
$this->client->shouldNotReceive('search');
|
||||
|
||||
self::assertNull($this->service->tryGetAlbumCover(Album::factory()->create()));
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
private static function parseFixture(string $name): array
|
||||
{
|
||||
return json_decode(file_get_contents(__DIR__ . '/../../blobs/spotify/' . $name), true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
config([
|
||||
'koel.spotify.client_id' => null,
|
||||
'koel.spotify.client_secret' => null,
|
||||
]);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
56
tests/blobs/spotify/search-album.json
Normal file
56
tests/blobs/spotify/search-album.json
Normal file
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"albums": {
|
||||
"href": "https://api.spotify.com/v1/search?query=Epica+artist%3AKamelot&type=album&locale=en-US%2Cen%3Bq%3D0.9%2Cvi%3Bq%3D0.8&offset=0&limit=1",
|
||||
"items": [
|
||||
{
|
||||
"album_type": "album",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/7gTbq5nTZGQIUgjEGXQpOS"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/7gTbq5nTZGQIUgjEGXQpOS",
|
||||
"id": "7gTbq5nTZGQIUgjEGXQpOS",
|
||||
"name": "Kamelot",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:7gTbq5nTZGQIUgjEGXQpOS"
|
||||
}
|
||||
],
|
||||
"available_markets": [],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/5WEchV3TKJFL1rHkggBAtB"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/5WEchV3TKJFL1rHkggBAtB",
|
||||
"id": "5WEchV3TKJFL1rHkggBAtB",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://foo/bar.jpg",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e02ad9329d7ab35e35816c0bd3b",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d00004851ad9329d7ab35e35816c0bd3b",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Epica",
|
||||
"release_date": "2003-03-03",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 16,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:5WEchV3TKJFL1rHkggBAtB"
|
||||
}
|
||||
],
|
||||
"limit": 1,
|
||||
"next": "https://api.spotify.com/v1/search?query=Epica+artist%3AKamelot&type=album&locale=en-US%2Cen%3Bq%3D0.9%2Cvi%3Bq%3D0.8&offset=1&limit=1",
|
||||
"offset": 0,
|
||||
"previous": null,
|
||||
"total": 1000
|
||||
}
|
||||
}
|
52
tests/blobs/spotify/search-artist.json
Normal file
52
tests/blobs/spotify/search-artist.json
Normal file
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"artists": {
|
||||
"href": "https://api.spotify.com/v1/search?query=Kamelot&type=artist&locale=en-US%2Cen%3Bq%3D0.9%2Cvi%3Bq%3D0.8&offset=0&limit=1",
|
||||
"items": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/7gTbq5nTZGQIUgjEGXQpOS"
|
||||
},
|
||||
"followers": {
|
||||
"href": null,
|
||||
"total": 333740
|
||||
},
|
||||
"genres": [
|
||||
"gothic symphonic metal",
|
||||
"melodic metal",
|
||||
"neo classical metal",
|
||||
"power metal",
|
||||
"progressive metal",
|
||||
"symphonic metal"
|
||||
],
|
||||
"href": "https://api.spotify.com/v1/artists/7gTbq5nTZGQIUgjEGXQpOS",
|
||||
"id": "7gTbq5nTZGQIUgjEGXQpOS",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://foo/bar.jpg",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 320,
|
||||
"url": "https://i.scdn.co/image/ab676161000051745f878a0070d7800c18a508b7",
|
||||
"width": 320
|
||||
},
|
||||
{
|
||||
"height": 160,
|
||||
"url": "https://i.scdn.co/image/ab6761610000f1785f878a0070d7800c18a508b7",
|
||||
"width": 160
|
||||
}
|
||||
],
|
||||
"name": "Kamelot",
|
||||
"popularity": 48,
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:7gTbq5nTZGQIUgjEGXQpOS"
|
||||
}
|
||||
],
|
||||
"limit": 1,
|
||||
"next": "https://api.spotify.com/v1/search?query=Kamelot&type=artist&locale=en-US%2Cen%3Bq%3D0.9%2Cvi%3Bq%3D0.8&offset=1&limit=1",
|
||||
"offset": 0,
|
||||
"previous": null,
|
||||
"total": 10
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue