feat: improve Spotify integration

This commit is contained in:
Phan An 2022-07-18 13:00:37 +02:00
parent 5ecfc89aa6
commit cebbf13107
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
25 changed files with 607 additions and 235 deletions

View file

@ -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

View 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.');
}
}

View file

@ -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

View file

@ -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'),

View file

@ -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;
});

View file

@ -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

View file

@ -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> */

View file

@ -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

View file

@ -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;
}
}

View file

@ -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;
});
}
/**

View file

@ -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) {
}
}

View file

@ -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);
});
}
/**

View 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);
}
}

View file

@ -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'
);
}

View file

@ -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) : '',

View file

@ -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) : '',

View file

@ -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'));
}

View file

@ -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));
}
}

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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);
}
}

View 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();
}
}

View 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();
}
}

View 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
}
}

View 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
}
}