feat: rework 3rd integration

This commit is contained in:
Phan An 2022-08-08 18:00:59 +02:00
parent f1d33b98e8
commit f010c773a1
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
58 changed files with 717 additions and 822 deletions

View file

@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Facades\Log;
/**
* Get a URL for static file requests.
* If this installation of Koel has a CDN_URL configured, use it as the base.
@ -38,3 +40,26 @@ function koel_version(): string
{
return trim(file_get_contents(base_path('.version')));
}
function attempt(callable $callback, bool $log = true): mixed
{
try {
return $callback();
} catch (Throwable $e) {
if ($log) {
Log::error('Failed attempt', ['error' => $e]);
}
return null;
}
}
function attempt_if($condition, callable $callback, bool $log = true): mixed
{
return value($condition) ? attempt($callback, $log) : null;
}
function attempt_unless($condition, callable $callback, bool $log = true): mixed
{
return !value($condition) ? attempt($callback, $log) : null;
}

View file

@ -21,9 +21,6 @@ class DataController extends Controller
/** @param User $currentUser */
public function __construct(
private LastfmService $lastfmService,
private YouTubeService $youTubeService,
private ITunesService $iTunesService,
private MediaCacheService $mediaCacheService,
private SettingRepository $settingRepository,
private PlaylistRepository $playlistRepository,
@ -46,9 +43,9 @@ class DataController extends Controller
),
'users' => $this->currentUser->is_admin ? $this->userRepository->getAll() : [],
'currentUser' => $this->currentUser,
'useLastfm' => $this->lastfmService->used(),
'useYouTube' => $this->youTubeService->enabled(),
'useiTunes' => $this->iTunesService->used(),
'useLastfm' => LastfmService::used(),
'useYouTube' => YouTubeService::enabled(),
'useiTunes' => ITunesService::used(),
'allowDownload' => config('koel.download.allow'),
'supportsTranscoding' => config('koel.streaming.ffmpeg_path')
&& is_executable(config('koel.streaming.ffmpeg_path')),

View file

@ -22,7 +22,7 @@ class LastfmController extends Controller
public function connect()
{
abort_unless(
$this->lastfm->enabled(),
LastfmService::enabled(),
Response::HTTP_NOT_IMPLEMENTED,
'Koel is not configured to use with Last.fm yet.'
);
@ -34,7 +34,7 @@ class LastfmController extends Controller
$this->tokenManager->createToken($this->currentUser)->plainTextToken
));
$url = sprintf('https://www.last.fm/api/auth/?api_key=%s&cb=%s', $this->lastfm->getKey(), $callbackUrl);
$url = sprintf('https://www.last.fm/api/auth/?api_key=%s&cb=%s', config('koel.lastfm.key'), $callbackUrl);
return redirect($url);
}

View file

@ -18,8 +18,6 @@ class DataController extends Controller
{
/** @param User $user */
public function __construct(
private LastfmService $lastfmService,
private YouTubeService $youTubeService,
private ITunesService $iTunesService,
private SettingRepository $settingRepository,
private PlaylistRepository $playlistRepository,
@ -34,9 +32,9 @@ class DataController extends Controller
return response()->json([
'settings' => $this->user->is_admin ? $this->settingRepository->getAllAsKeyValueArray() : [],
'playlists' => $this->playlistRepository->getAllByCurrentUser(),
'current_user' => UserResource::make($this->user),
'use_last_fm' => $this->lastfmService->used(),
'use_you_tube' => $this->youTubeService->enabled(), // @todo clean this mess up
'current_user' => UserResource::make($this->user, true),
'use_last_fm' => LastfmService::used(),
'use_you_tube' => YouTubeService::enabled(),
'use_i_tunes' => $this->iTunesService->used(),
'allow_download' => config('koel.download.allow'),
'supports_transcoding' => config('koel.streaming.ffmpeg_path')

View file

@ -7,7 +7,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function __construct(private User $user)
public function __construct(private User $user, private bool $includePreferences = false)
{
parent::__construct($user);
}
@ -22,6 +22,7 @@ class UserResource extends JsonResource
'email' => $this->user->email,
'avatar' => $this->user->avatar,
'is_admin' => $this->user->is_admin,
'preferences' => $this->when($this->includePreferences, $this->user->preferences),
];
}
}

View file

@ -2,7 +2,6 @@
namespace App\Jobs;
use App\Models\Album;
use App\Models\Song;
use App\Models\User;
use App\Services\LastfmService;
@ -19,25 +18,12 @@ class ScrobbleJob implements ShouldQueue
use Queueable;
use SerializesModels;
private User $user;
private Song $song;
private int $timestamp;
public function __construct(User $user, Song $song, int $timestamp)
public function __construct(public User $user, public Song $song, public int $timestamp)
{
$this->user = $user;
$this->song = $song;
$this->timestamp = $timestamp;
}
public function handle(LastfmService $lastfmService): void
{
$lastfmService->scrobble(
$this->song->artist->name,
$this->song->title,
$this->timestamp,
$this->song->album->name === Album::UNKNOWN_NAME ? '' : $this->song->album->name,
$this->user->lastfm_session_key
);
$lastfmService->scrobble($this->song, $this->user, $this->timestamp);
}
}

View file

@ -3,27 +3,17 @@
namespace App\Listeners;
use App\Events\SongsBatchLiked;
use App\Models\Song;
use App\Services\LastfmService;
use App\Values\LastfmLoveTrackParameters;
use Illuminate\Contracts\Queue\ShouldQueue;
class LoveMultipleTracksOnLastfm implements ShouldQueue
{
private LastfmService $lastfm;
public function __construct(LastfmService $lastfm)
public function __construct(private LastfmService $lastfm)
{
$this->lastfm = $lastfm;
}
public function handle(SongsBatchLiked $event): void
{
$this->lastfm->batchToggleLoveTracks(
$event->songs->map(static function (Song $song): LastfmLoveTrackParameters {
return LastfmLoveTrackParameters::make($song->title, $song->artist->name);
}),
$event->user
);
$this->lastfm->batchToggleLoveTracks($event->songs, $event->user, true);
}
}

View file

@ -4,7 +4,6 @@ namespace App\Listeners;
use App\Events\SongLikeToggled;
use App\Services\LastfmService;
use App\Values\LastfmLoveTrackParameters;
use Illuminate\Contracts\Queue\ShouldQueue;
class LoveTrackOnLastfm implements ShouldQueue
@ -16,7 +15,7 @@ class LoveTrackOnLastfm implements ShouldQueue
public function handle(SongLikeToggled $event): void
{
if (
!$this->lastfm->enabled() ||
!LastfmService::enabled() ||
!$event->interaction->user->lastfm_session_key ||
$event->interaction->song->artist->is_unknown
) {
@ -24,8 +23,8 @@ class LoveTrackOnLastfm implements ShouldQueue
}
$this->lastfm->toggleLoveTrack(
LastfmLoveTrackParameters::make($event->interaction->song->title, $event->interaction->song->artist->name),
$event->interaction->user->lastfm_session_key,
$event->interaction->song,
$event->interaction->user,
$event->interaction->liked
);
}

View file

@ -3,28 +3,17 @@
namespace App\Listeners;
use App\Events\SongsBatchUnliked;
use App\Models\Song;
use App\Services\LastfmService;
use App\Values\LastfmLoveTrackParameters;
use Illuminate\Contracts\Queue\ShouldQueue;
class UnloveMultipleTracksOnLastfm implements ShouldQueue
{
private LastfmService $lastfm;
public function __construct(LastfmService $lastfm)
public function __construct(private LastfmService $lastfm)
{
$this->lastfm = $lastfm;
}
public function handle(SongsBatchUnliked $event): void
{
$this->lastfm->batchToggleLoveTracks(
$event->songs->map(static function (Song $song): LastfmLoveTrackParameters {
return LastfmLoveTrackParameters::make($song->title, $song->artist->name);
}),
$event->user,
false
);
$this->lastfm->batchToggleLoveTracks($event->songs, $event->user, false);
}
}

View file

@ -8,25 +8,16 @@ use Illuminate\Contracts\Queue\ShouldQueue;
class UpdateLastfmNowPlaying implements ShouldQueue
{
private LastfmService $lastfm;
public function __construct(LastfmService $lastfm)
public function __construct(private LastfmService $lastfm)
{
$this->lastfm = $lastfm;
}
public function handle(SongStartedPlaying $event): void
{
if (!$this->lastfm->enabled() || !$event->user->lastfm_session_key || $event->song->artist->is_unknown) {
if (!LastfmService::enabled() || !$event->user->lastfm_session_key || $event->song->artist->is_unknown) {
return;
}
$this->lastfm->updateNowPlaying(
$event->song->artist->name,
$event->song->title,
$event->song->album->is_unknown ? '' : $event->song->album->name,
$event->song->length,
$event->user->lastfm_session_key
);
$this->lastfm->updateNowPlaying($event->song, $event->user);
}
}

View file

@ -4,9 +4,7 @@ namespace App\Models;
use App\Facades\Download;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
use ZipArchive;
class SongZipArchive
@ -43,12 +41,10 @@ class SongZipArchive
public function addSong(Song $song): self
{
try {
attempt(function () use ($song): void {
$path = Download::fromSong($song);
$this->archive->addFile($path, $this->generateZipContentFileNameFromPath($path));
} catch (Throwable $e) {
Log::error($e);
}
});
return $this;
}

View file

@ -3,33 +3,15 @@
namespace App\Observers;
use App\Models\Album;
use Illuminate\Log\Logger;
use Throwable;
class AlbumObserver
{
private Logger $logger;
public function __construct(Logger $logger)
{
$this->logger = $logger;
}
public function deleted(Album $album): void
{
$this->deleteAlbumCover($album);
}
private function deleteAlbumCover(Album $album): void
{
if (!$album->has_cover) {
return;
}
try {
unlink($album->cover_path);
} catch (Throwable $e) {
$this->logger->error($e);
}
attempt(static fn () => unlink($album->cover_path));
}
}

View file

@ -2,6 +2,9 @@
namespace App\Providers;
use App\Services\LastfmService;
use App\Services\MusicEncyclopedia;
use App\Services\NullMusicEncyclopedia;
use App\Services\SpotifyService;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Schema\Builder;
@ -37,6 +40,10 @@ class AppServiceProvider extends ServiceProvider
? new SpotifySession(config('koel.spotify.client_id'), config('koel.spotify.client_secret'))
: null;
});
$this->app->bind(MusicEncyclopedia::class, function () {
return $this->app->get(LastfmService::enabled() ? LastfmService::class : NullMusicEncyclopedia::class);
});
}
/**

View file

@ -5,7 +5,6 @@ namespace App\Repositories;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Throwable;
abstract class Repository implements RepositoryInterface
{
@ -20,10 +19,7 @@ abstract class Repository implements RepositoryInterface
// This instantiation may fail during a console command if e.g. APP_KEY is empty,
// rendering the whole installation failing.
try {
$this->auth = app(Guard::class);
} catch (Throwable) {
}
attempt(fn () => $this->auth = app(Guard::class), false);
}
private static function guessModelClass(): string

View file

@ -3,19 +3,16 @@
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Throwable;
class ImageData implements Rule
{
public function passes($attribute, $value): bool
{
try {
return attempt(static function () use ($value) {
[$header,] = explode(';', $value);
return (bool) preg_match('/data:image\/(jpe?g|png|webp|gif)/i', $header);
} catch (Throwable) {
return false;
}
}, false) ?? false;
}
public function message(): string

View file

@ -6,7 +6,6 @@ use getID3;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Throwable;
use Webmozart\Assert\Assert;
class SupportedAudioFile implements Rule
@ -16,16 +15,14 @@ class SupportedAudioFile implements Rule
/** @param UploadedFile $value */
public function passes($attribute, $value): bool
{
try {
return attempt(static function () use ($value) {
Assert::oneOf(
Arr::get((new getID3())->analyze($value->getRealPath()), 'fileformat'),
self::SUPPORTED_FORMATS
);
return true;
} catch (Throwable) {
return false;
}
}, false) ?? false;
}
public function message(): string

View file

@ -4,14 +4,12 @@ namespace App\Rules;
use App\Values\SmartPlaylistRule;
use Illuminate\Contracts\Validation\Rule;
use Throwable;
class ValidSmartPlaylistRulePayload implements Rule
{
/** @param array $value */
public function passes($attribute, $value): bool
{
try {
return attempt(static function () use ($value) {
foreach ((array) $value as $ruleGroupConfig) {
foreach ($ruleGroupConfig['rules'] as $rule) {
SmartPlaylistRule::assertConfig($rule, false);
@ -19,9 +17,7 @@ class ValidSmartPlaylistRulePayload implements Rule
}
return true;
} catch (Throwable) {
return false;
}
}, false) ?? false;
}
public function message(): string

View file

@ -1,24 +1,21 @@
<?php
namespace App\Services;
namespace App\Services\ApiClients;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Client as GuzzleHttpClient;
use GuzzleHttp\Promise\Promise;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Log\Logger;
use Illuminate\Support\Str;
use InvalidArgumentException;
use SimpleXMLElement;
use Webmozart\Assert\Assert;
/**
* @method object|null get(string $uri, array $data = [], bool $appendKey = true)
* @method object post($uri, array $data = [], bool $appendKey = true)
* @method object put($uri, array $data = [], bool $appendKey = true)
* @method object patch($uri, array $data = [], bool $appendKey = true)
* @method object head($uri, array $data = [], bool $appendKey = true)
* @method object delete($uri, array $data = [], bool $appendKey = true)
* @method mixed get(string $uri, array $data = [], bool $appendKey = true)
* @method mixed post($uri, array $data = [], bool $appendKey = true)
* @method mixed put($uri, array $data = [], bool $appendKey = true)
* @method mixed patch($uri, array $data = [], bool $appendKey = true)
* @method mixed head($uri, array $data = [], bool $appendKey = true)
* @method mixed delete($uri, array $data = [], bool $appendKey = true)
* @method Promise getAsync(string $uri, array $data = [], bool $appendKey = true)
* @method Promise postAsync($uri, array $data = [], bool $appendKey = true)
* @method Promise putAsync($uri, array $data = [], bool $appendKey = true)
@ -44,9 +41,6 @@ abstract class ApiClient
];
protected string $responseFormat = 'json';
protected Client $client;
protected Cache $cache;
protected Logger $logger;
/**
* The query parameter name for the key.
@ -55,11 +49,8 @@ abstract class ApiClient
*/
protected string $keyParam = 'key';
public function __construct(Client $client, Cache $cache, Logger $logger)
public function __construct(protected GuzzleHttpClient $wrapped)
{
$this->client = $client;
$this->cache = $cache;
$this->logger = $logger;
}
/**
@ -72,12 +63,11 @@ abstract class ApiClient
* an "API signature" of the request. Appending an API key will break the request.
* @param array $params An array of parameters
*
* @return mixed|SimpleXMLElement|void
*/
public function request(string $method, string $uri, bool $appendKey = true, array $params = []) // @phpcs:ignore
public function request(string $method, string $uri, bool $appendKey = true, array $params = []): mixed
{
try {
$body = (string) $this->getClient()
return attempt(function () use ($method, $uri, $appendKey, $params) {
$body = (string) $this->wrapped
->$method($this->buildUrl($uri, $appendKey), ['form_params' => $params])
->getBody();
@ -90,14 +80,12 @@ abstract class ApiClient
}
return $body;
} catch (ClientException $e) {
$this->logger->error($e);
}
});
}
public function requestAsync(string $method, string $uri, bool $appendKey = true, array $params = []): Promise
{
return $this->getClient()->$method($this->buildUrl($uri, $appendKey), ['form_params' => $params]);
return $this->wrapped->$method($this->buildUrl($uri, $appendKey), ['form_params' => $params]);
}
/**
@ -155,11 +143,6 @@ abstract class ApiClient
return $uri;
}
public function getClient(): Client
{
return $this->client;
}
abstract public function getKey(): ?string;
abstract public function getSecret(): ?string;

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\ApiClients;
class ITunesClient extends ApiClient
{
public function getKey(): ?string
{
return null;
}
public function getSecret(): ?string
{
return null;
}
public function getEndpoint(): ?string
{
return config('koel.itunes.endpoint');
}
}

View file

@ -0,0 +1,95 @@
<?php
namespace App\Services\ApiClients;
use GuzzleHttp\Promise\Promise;
class LastfmClient extends ApiClient
{
protected string $keyParam = 'api_key';
public function post($uri, array $data = [], bool $appendKey = true): mixed
{
return parent::post($uri, $this->buildAuthCallParams($data), $appendKey);
}
public function postAsync($uri, array $data = [], bool $appendKey = true): Promise
{
return parent::postAsync($uri, $this->buildAuthCallParams($data), $appendKey);
}
/**
* Get Last.fm's session key for the authenticated user using a token.
*
* @param string $token The token after successfully connecting to Last.fm
*
* @see http://www.last.fm/api/webauth#4
*/
public function getSessionKey(string $token): ?string
{
$query = $this->buildAuthCallParams([
'method' => 'auth.getSession',
'token' => $token,
], true);
return attempt(fn () => $this->get("/?$query&format=json", [], false)->session->key);
}
/**
* Build the parameters to use for _authenticated_ Last.fm API calls.
* Such calls require:
* - The API key (api_key)
* - The API signature (api_sig).
*
* @see http://www.last.fm/api/webauth#5
*
* @param array $params The array of parameters
* @param bool $toString Whether to turn the array into a query string
*
* @return array<mixed>|string
*/
private function buildAuthCallParams(array $params, bool $toString = false): array|string
{
$params['api_key'] = $this->getKey();
ksort($params);
// Generate the API signature.
// @link http://www.last.fm/api/webauth#6
$str = '';
foreach ($params as $name => $value) {
$str .= $name . $value;
}
$str .= $this->getSecret();
$params['api_sig'] = md5($str);
if (!$toString) {
return $params;
}
$query = '';
foreach ($params as $key => $value) {
$query .= "$key=$value&";
}
return rtrim($query, '&');
}
public function getKey(): ?string
{
return config('koel.lastfm.key');
}
public function getEndpoint(): ?string
{
return config('koel.lastfm.endpoint');
}
public function getSecret(): ?string
{
return config('koel.lastfm.secret');
}
}

View file

@ -1,33 +1,23 @@
<?php
namespace App\Services;
namespace App\Services\ApiClients;
use App\Exceptions\SpotifyIntegrationDisabledException;
use App\Services\SpotifyService;
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, array|object $options = [])
*/
class SpotifyClient
{
public function __construct(
public SpotifyWebAPI $wrapped,
private ?Session $session,
private Cache $cache,
private LoggerInterface $log
) {
public function __construct(public SpotifyWebAPI $wrapped, private ?Session $session, private Cache $cache)
{
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]);
}
attempt(fn () => $this->setAccessToken());
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\ApiClients;
class YouTubeClient extends ApiClient
{
public function getKey(): ?string
{
return config('koel.youtube.key');
}
public function getSecret(): ?string
{
return null;
}
public function getEndpoint(): ?string
{
return config('koel.youtube.endpoint');
}
}

View file

@ -1,12 +0,0 @@
<?php
namespace App\Services;
interface ApiConsumerInterface
{
public function getEndpoint(): ?string;
public function getKey(): ?string;
public function getSecret(): ?string;
}

View file

@ -4,14 +4,10 @@ namespace App\Services;
use GuzzleHttp\Client;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Log\Logger;
use Throwable;
class ApplicationInformationService
{
private const CACHE_KEY = 'latestKoelVersion';
public function __construct(private Client $client, private Cache $cache, private Logger $logger)
public function __construct(private Client $client, private Cache $cache)
{
}
@ -20,15 +16,11 @@ class ApplicationInformationService
*/
public function getLatestVersionNumber(): string
{
return $this->cache->remember(self::CACHE_KEY, now()->addDay(), function (): string {
try {
return attempt(function () {
return $this->cache->remember('latestKoelVersion', now()->addDay(), function (): string {
return json_decode($this->client->get('https://api.github.com/repos/koel/koel/tags')->getBody())[0]
->name;
} catch (Throwable $e) {
$this->logger->error($e);
return koel_version();
}
});
});
}) ?? koel_version();
}
}

View file

@ -13,7 +13,6 @@ use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Arr;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
use Throwable;
class FileSynchronizer
{
@ -101,7 +100,7 @@ class FileSynchronizer
*/
private function tryGenerateAlbumCover(Album $album, ?array $coverData): void
{
try {
attempt(function () use ($album, $coverData): void {
// If the album has no cover, we try to get the cover image from existing tag data
if ($coverData) {
$extension = explode('/', $coverData['image_mime']);
@ -119,8 +118,7 @@ class FileSynchronizer
$extension = pathinfo($cover, PATHINFO_EXTENSION);
$this->mediaMetadataService->writeAlbumCover($album, $cover, $extension);
}
} catch (Throwable) {
}
}, false);
}
/**
@ -152,11 +150,7 @@ class FileSynchronizer
private static function isImage(string $path): bool
{
try {
return (bool) exif_imagetype($path);
} catch (Throwable) {
return false;
}
return attempt(static fn () => (bool) exif_imagetype($path)) ?? false;
}
/**

View file

@ -3,7 +3,6 @@
namespace App\Services;
use SplFileInfo;
use Throwable;
class Helper
{
@ -21,11 +20,6 @@ class Helper
$file = is_string($file) ? new SplFileInfo($file) : $file;
// Workaround for #344, where getMTime() fails for certain files with Unicode names on Windows.
try {
return $file->getMTime();
} catch (Throwable) {
// Just use current stamp for mtime.
return time();
}
return attempt(static fn () => $file->getMTime()) ?? time();
}
}

View file

@ -2,14 +2,19 @@
namespace App\Services;
use Throwable;
use App\Services\ApiClients\ITunesClient;
use Illuminate\Cache\Repository as Cache;
class ITunesService extends ApiClient implements ApiConsumerInterface
class ITunesService
{
public function __construct(private ITunesClient $client, private Cache $cache)
{
}
/**
* Determines whether to use iTunes services.
*/
public function used(): bool
public static function used(): bool
{
return (bool) config('koel.itunes.enabled');
}
@ -23,7 +28,7 @@ class ITunesService extends ApiClient implements ApiConsumerInterface
*/
public function getTrackUrl(string $term, string $album = '', string $artist = ''): ?string
{
try {
return attempt(function () use ($term, $album, $artist): ?string {
return $this->cache->remember(
md5("itunes_track_url_$term$album$artist"),
24 * 60 * 7,
@ -35,9 +40,7 @@ class ITunesService extends ApiClient implements ApiConsumerInterface
'limit' => 1,
];
$response = json_decode(
$this->getClient()->get($this->getEndpoint(), ['query' => $params])->getBody()
);
$response = $this->client->get('/', ['query' => $params]);
if (!$response->resultCount) {
return null;
@ -45,28 +48,10 @@ class ITunesService extends ApiClient implements ApiConsumerInterface
$trackUrl = $response->results[0]->trackViewUrl;
$connector = parse_url($trackUrl, PHP_URL_QUERY) ? '&' : '?';
return $trackUrl . "{$connector}at=" . config('koel.itunes.affiliate_id');
}
);
} catch (Throwable $e) {
$this->logger->error($e);
return null;
}
}
public function getKey(): ?string
{
return null;
}
public function getSecret(): ?string
{
return null;
}
public function getEndpoint(): ?string
{
return config('koel.itunes.endpoint');
});
}
}

View file

@ -4,257 +4,126 @@ namespace App\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Models\User;
use App\Services\ApiClients\LastfmClient;
use App\Values\AlbumInformation;
use App\Values\ArtistInformation;
use App\Values\LastfmLoveTrackParameters;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\Utils;
use Illuminate\Support\Collection;
use Throwable;
class LastfmService extends ApiClient implements ApiConsumerInterface
class LastfmService implements MusicEncyclopedia
{
/**
* Override the key param, since, again, Last.fm wants to be different.
*/
protected string $keyParam = 'api_key';
public function __construct(private LastfmClient $client)
{
}
/**
* Determine if our application is using Last.fm.
*/
public function used(): bool
public static function used(): bool
{
return (bool) $this->getKey();
return (bool) config('koel.lastfm.key');
}
/**
* Determine if Last.fm integration is enabled.
*/
public function enabled(): bool
public static function enabled(): bool
{
return $this->getKey() && $this->getSecret();
return config('koel.lastfm.key') && config('koel.lastfm.secret');
}
public function getArtistInformation(Artist $artist): ?ArtistInformation
{
if (!$this->enabled()) {
return null;
}
return attempt_if(static::enabled(), function () use ($artist): ?ArtistInformation {
$name = urlencode($artist->name);
$response = $this->client->get("?method=artist.getInfo&autocorrect=1&artist=$name&format=json");
$name = urlencode($artist->name);
try {
return $this->cache->remember(
md5("lastfm_artist_$name"),
now()->addWeek(),
function () use ($name): ?ArtistInformation {
$response = $this->get("?method=artist.getInfo&autocorrect=1&artist=$name&format=json");
return $response?->artist ? ArtistInformation::fromLastFmData($response->artist) : null;
}
);
} catch (Throwable $e) {
$this->logger->error($e);
return null;
}
return $response?->artist ? ArtistInformation::fromLastFmData($response->artist) : null;
});
}
public function getAlbumInformation(Album $album): ?AlbumInformation
{
if (!$this->enabled()) {
return null;
}
return attempt_if(static::enabled(), function () use ($album): ?AlbumInformation {
$albumName = urlencode($album->name);
$artistName = urlencode($album->artist->name);
$albumName = urlencode($album->name);
$artistName = urlencode($album->artist->name);
$response = $this->client
->get("?method=album.getInfo&autocorrect=1&album=$albumName&artist=$artistName&format=json");
try {
$cacheKey = md5("lastfm_album_{$albumName}_{$artistName}");
return $this->cache->remember(
$cacheKey,
now()->addWeek(),
function () use ($albumName, $artistName): ?AlbumInformation {
$response = $this
->get("?method=album.getInfo&autocorrect=1&album=$albumName&artist=$artistName&format=json");
return $response?->album ? AlbumInformation::fromLastFmData($response->album) : null;
}
);
} catch (Throwable $e) {
$this->logger->error($e);
return null;
}
return $response?->album ? AlbumInformation::fromLastFmData($response->album) : null;
});
}
/**
* Get Last.fm's session key for the authenticated user using a token.
*
* @param string $token The token after successfully connecting to Last.fm
*
* @see http://www.last.fm/api/webauth#4
*/
public function getSessionKey(string $token): ?string
public function scrobble(Song $song, User $user, int $timestamp): void
{
$query = $this->buildAuthCallParams([
'method' => 'auth.getSession',
'token' => $token,
], true);
try {
return $this->get("/?$query&format=json", [], false)->session->key;
} catch (Throwable $e) {
$this->logger->error($e);
return null;
}
}
public function scrobble(
string $artistName,
string $trackName,
$timestamp,
string $albumName,
string $sessionKey
): void {
$params = [
'artist' => $artistName,
'track' => $trackName,
'artist' => $song->artist->name,
'track' => $song->title,
'timestamp' => $timestamp,
'sk' => $sessionKey,
'sk' => $user->lastfm_session_key,
'method' => 'track.scrobble',
];
if ($albumName) {
$params['album'] = $albumName;
if ($song->album->name !== Album::UNKNOWN_NAME) {
$params['album'] = $song->album->name;
}
try {
$this->post('/', $this->buildAuthCallParams($params), false);
} catch (Throwable $e) {
$this->logger->error($e);
}
attempt(fn () => $this->client->post('/', $params, false));
}
public function toggleLoveTrack(LastfmLoveTrackParameters $params, string $sessionKey, bool $love = true): void
public function toggleLoveTrack(Song $song, User $user, bool $love): void
{
try {
$this->post('/', $this->buildAuthCallParams([
'track' => $params->trackName,
'artist' => $params->artistName,
'sk' => $sessionKey,
'method' => $love ? 'track.love' : 'track.unlove',
]), false);
} catch (Throwable $e) {
$this->logger->error($e);
}
attempt(fn () => $this->client->post('/', [
'track' => $song->title,
'artist' => $song->artist->name,
'sk' => $user->lastfm_session_key,
'method' => $love ? 'track.love' : 'track.unlove',
], false));
}
/**
* @param Collection|array<LastfmLoveTrackParameters> $parameterCollection
* @param Collection|array<array-key, Song> $songs
*/
public function batchToggleLoveTracks(Collection $parameterCollection, string $sessionKey, bool $love = true): void
public function batchToggleLoveTracks(Collection $songs, User $user, bool $love): void
{
$promises = $parameterCollection->map(
fn (LastfmLoveTrackParameters $params): Promise => $this->postAsync('/', $this->buildAuthCallParams([
'track' => $params->trackName,
'artist' => $params->artistName,
'sk' => $sessionKey,
'method' => $love ? 'track.love' : 'track.unlove',
]), false)
$promises = $songs->map(
function (Song $song) use ($user, $love): Promise {
return $this->client->postAsync('/', [
'track' => $song->title,
'artist' => $song->artist->name,
'sk' => $user->lastfm_session_key,
'method' => $love ? 'track.love' : 'track.unlove',
], false);
}
);
try {
Utils::unwrap($promises);
} catch (Throwable $e) {
$this->logger->error($e);
}
attempt(static fn () => Utils::unwrap($promises));
}
public function updateNowPlaying(
string $artistName,
string $trackName,
string $albumName,
int|float $duration,
string $sessionKey
): void {
public function updateNowPlaying(Song $song, User $user): void
{
$params = [
'artist' => $artistName,
'track' => $trackName,
'duration' => $duration,
'sk' => $sessionKey,
'artist' => $song->artist->name,
'track' => $song->title,
'duration' => $song->length,
'sk' => $user->lastfm_session_key,
'method' => 'track.updateNowPlaying',
];
if ($albumName) {
$params['album'] = $albumName;
if ($song->album->name !== Album::UNKNOWN_NAME) {
$params['album'] = $song->album->name;
}
try {
$this->post('/', $this->buildAuthCallParams($params), false);
} catch (Throwable $e) {
$this->logger->error($e);
}
attempt(fn () => $this->client->post('/', $params, false));
}
/**
* Build the parameters to use for _authenticated_ Last.fm API calls.
* Such calls require:
* - The API key (api_key)
* - The API signature (api_sig).
*
* @see http://www.last.fm/api/webauth#5
*
* @param array $params The array of parameters
* @param bool $toString Whether to turn the array into a query string
*
* @return array<mixed>|string
*/
public function buildAuthCallParams(array $params, bool $toString = false): array|string
public function getSessionKey(string $token): ?string
{
$params['api_key'] = $this->getKey();
ksort($params);
// Generate the API signature.
// @link http://www.last.fm/api/webauth#6
$str = '';
foreach ($params as $name => $value) {
$str .= $name . $value;
}
$str .= $this->getSecret();
$params['api_sig'] = md5($str);
if (!$toString) {
return $params;
}
$query = '';
foreach ($params as $key => $value) {
$query .= "$key=$value&";
}
return rtrim($query, '&');
}
public function getKey(): ?string
{
return config('koel.lastfm.key');
}
public function getEndpoint(): ?string
{
return config('koel.lastfm.endpoint');
}
public function getSecret(): ?string
{
return config('koel.lastfm.secret');
return $this->client->getSessionKey($token);
}
public function setUserSessionKey(User $user, ?string $sessionKey): void

View file

@ -6,13 +6,14 @@ use App\Models\Album;
use App\Models\Artist;
use App\Values\AlbumInformation;
use App\Values\ArtistInformation;
use Throwable;
use Illuminate\Cache\Repository as Cache;
class MediaInformationService
{
public function __construct(
private LastfmService $lastfmService,
private MediaMetadataService $mediaMetadataService
private MusicEncyclopedia $encyclopedia,
private MediaMetadataService $mediaMetadataService,
private Cache $cache
) {
}
@ -22,35 +23,41 @@ class MediaInformationService
return null;
}
$info = $this->lastfmService->getAlbumInformation($album) ?: AlbumInformation::make();
if (!$album->has_cover) {
try {
$this->mediaMetadataService->tryDownloadAlbumCover($album);
$info->cover = $album->cover;
} catch (Throwable) {
}
if ($this->cache->has('album.info.' . $album->id)) {
return $this->cache->get('album.info.' . $album->id);
}
$info = $this->encyclopedia->getAlbumInformation($album) ?: AlbumInformation::make();
attempt_unless($album->has_cover, function () use ($info, $album): void {
$this->mediaMetadataService->tryDownloadAlbumCover($album);
$info->cover = $album->cover;
});
$this->cache->put('album.info.' . $album->id, $info, now()->addWeek());
return $info;
}
public function getArtistInformation(Artist $artist): ?ArtistInformation
{
if ($artist->is_unknown) {
if ($artist->is_unknown || $artist->is_various) {
return null;
}
$info = $this->lastfmService->getArtistInformation($artist) ?: ArtistInformation::make();
if (!$artist->has_image) {
try {
$this->mediaMetadataService->tryDownloadArtistImage($artist);
$info->image = $artist->image;
} catch (Throwable) {
}
if ($this->cache->has('artist.info.' . $artist->id)) {
return $this->cache->get('artist.info.' . $artist->id);
}
$info = $this->encyclopedia->getArtistInformation($artist) ?: ArtistInformation::make();
attempt_unless($artist->has_image, function () use ($artist, $info): void {
$this->mediaMetadataService->tryDownloadArtistImage($artist);
$info->image = $artist->image;
});
$this->cache->put('artist.info.' . $artist->id, $info, now()->addWeek());
return $info;
}
}

View file

@ -5,16 +5,11 @@ namespace App\Services;
use App\Models\Album;
use App\Models\Artist;
use Illuminate\Support\Str;
use Psr\Log\LoggerInterface;
use Throwable;
class MediaMetadataService
{
public function __construct(
private SpotifyService $spotifyService,
private ImageWriter $imageWriter,
private LoggerInterface $logger
) {
public function __construct(private SpotifyService $spotifyService, private ImageWriter $imageWriter)
{
}
public function tryDownloadAlbumCover(Album $album): void
@ -37,7 +32,7 @@ class MediaMetadataService
?string $destination = '',
bool $cleanUp = true
): void {
try {
attempt(function () use ($album, $source, $extension, $destination, $cleanUp): void {
$extension = trim(strtolower($extension), '. ');
$destination = $destination ?: $this->generateAlbumCoverPath($extension);
$this->imageWriter->write($destination, $source);
@ -48,9 +43,7 @@ class MediaMetadataService
$album->update(['cover' => basename($destination)]);
$this->createThumbnailForAlbum($album);
} catch (Throwable $e) {
$this->logger->error($e);
}
});
}
public function tryDownloadArtistImage(Artist $artist): void
@ -73,7 +66,7 @@ class MediaMetadataService
?string $destination = '',
bool $cleanUp = true
): void {
try {
attempt(function () use ($artist, $source, $extension, $destination, $cleanUp): void {
$extension = trim(strtolower($extension), '. ');
$destination = $destination ?: $this->generateArtistImagePath($extension);
$this->imageWriter->write($destination, $source);
@ -83,9 +76,7 @@ class MediaMetadataService
}
$artist->update(['image' => basename($destination)]);
} catch (Throwable $e) {
$this->logger->error($e);
}
});
}
private function generateAlbumCoverPath(string $extension): string

View file

@ -0,0 +1,15 @@
<?php
namespace App\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Values\AlbumInformation;
use App\Values\ArtistInformation;
interface MusicEncyclopedia
{
public function getArtistInformation(Artist $artist): ?ArtistInformation;
public function getAlbumInformation(Album $album): ?AlbumInformation;
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Values\AlbumInformation;
use App\Values\ArtistInformation;
class NullMusicEncyclopedia implements MusicEncyclopedia
{
public function getArtistInformation(Artist $artist): ?ArtistInformation
{
return ArtistInformation::make();
}
public function getAlbumInformation(Album $album): ?AlbumInformation
{
return AlbumInformation::make();
}
}

View file

@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Services\ApiClients\SpotifyClient;
use Illuminate\Support\Arr;
class SpotifyService

View file

@ -22,13 +22,13 @@ class PhpStreamer extends Streamer implements DirectStreamerInterface
$rangeSet = RangeSet::createFromHeader(get_request_header('Range'));
$resource = new FileResource($this->song->path);
(new ResourceServlet($resource))->sendResource($rangeSet);
} catch (InvalidRangeHeaderException $e) {
} catch (InvalidRangeHeaderException) {
abort(Response::HTTP_BAD_REQUEST);
} catch (UnsatisfiableRangeException $e) {
} catch (UnsatisfiableRangeException) {
abort(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE);
} catch (NonExistentFileException $e) {
} catch (NonExistentFileException) {
abort(Response::HTTP_NOT_FOUND);
} catch (UnreadableFileException $e) {
} catch (UnreadableFileException) {
abort(Response::HTTP_INTERNAL_SERVER_ERROR);
} catch (SendFileFailureException $e) {
abort_unless(headers_sent(), Response::HTTP_INTERNAL_SERVER_ERROR);

View file

@ -3,16 +3,21 @@
namespace App\Services;
use App\Models\Song;
use Throwable;
use App\Services\ApiClients\YouTubeClient;
use Illuminate\Cache\Repository as Cache;
class YouTubeService extends ApiClient implements ApiConsumerInterface
class YouTubeService
{
public function __construct(private YouTubeClient $client, private Cache $cache)
{
}
/**
* Determine if our application is using YouTube.
*/
public function enabled(): bool
public static function enabled(): bool
{
return (bool) $this->getKey();
return (bool) config('koel.youtube.key');
}
public function searchVideosRelatedToSong(Song $song, string $pageToken = '') // @phpcs:ignore
@ -35,40 +40,17 @@ class YouTubeService extends ApiClient implements ApiConsumerInterface
* @param int $perPage Number of results per page
*
*/
public function search(string $q, string $pageToken = '', int $perPage = 10) // @phpcs:ignore
private function search(string $q, string $pageToken = '', int $perPage = 10) // @phpcs:ignore
{
if (!$this->enabled()) {
return null;
}
return attempt_if(static::enabled(), function () use ($q, $pageToken, $perPage) {
$uri = sprintf(
'search?part=snippet&type=video&maxResults=%s&pageToken=%s&q=%s',
$perPage,
urlencode($pageToken),
urlencode($q)
);
$uri = sprintf(
'search?part=snippet&type=video&maxResults=%s&pageToken=%s&q=%s',
$perPage,
urlencode($pageToken),
urlencode($q)
);
try {
return $this->cache->remember(md5("youtube_$uri"), 60 * 24 * 7, fn () => $this->get($uri));
} catch (Throwable $e) {
$this->logger->error($e);
return null;
}
}
public function getEndpoint(): ?string
{
return config('koel.youtube.endpoint');
}
public function getKey(): ?string
{
return config('koel.youtube.key');
}
public function getSecret(): ?string
{
return null;
return $this->cache->remember(md5("youtube_$uri"), now()->addWeek(), fn () => $this->client->get($uri));
});
}
}

View file

@ -5,7 +5,6 @@ namespace App\Values;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Throwable;
final class SmartPlaylistRuleGroup implements Arrayable
{
@ -15,11 +14,7 @@ final class SmartPlaylistRuleGroup implements Arrayable
public static function tryCreate(array $jsonArray): ?self
{
try {
return self::create($jsonArray);
} catch (Throwable) {
return null;
}
return attempt(static fn () => self::create($jsonArray));
}
public static function create(array $jsonArray): self

View file

@ -9,12 +9,11 @@ class ConvertUserPreferencesFromArrayToJson extends Migration
public function up(): void
{
User::all()->each(static function (User $user): void {
try {
attempt(static function () use ($user): void {
$preferences = unserialize($user->getRawOriginal('preferences'));
$user->preferences->lastFmSessionKey = Arr::get($preferences, 'lastfm_session_key');
$user->save();
} catch (Throwable $exception) {
}
}, false);
});
}
}

View file

@ -5,26 +5,22 @@ use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
return new class extends Migration {
public function up(): void
{
Schema::table('songs', static function (Blueprint $table): void {
// This migration is actually to fix a mistake that the original one was deleted.
// Therefore, we just "try" it and ignore on error.
try {
if (Schema::hasColumn('songs', 'contributing_artist_id')) {
Schema::disableForeignKeyConstraints();
attempt_if(Schema::hasColumn('songs', 'contributing_artist_id'), static function () use ($table): void {
Schema::disableForeignKeyConstraints();
if (DB::getDriverName() !== 'sqlite') { // @phpstan-ignore-line
$table->dropForeign('songs_contributing_artist_id_foreign');
}
$table->dropColumn('contributing_artist_id');
Schema::enableForeignKeyConstraints();
if (DB::getDriverName() !== 'sqlite') { // @phpstan-ignore-line
$table->dropForeign('songs_contributing_artist_id_foreign');
}
} catch (Throwable) {
}
$table->dropColumn('contributing_artist_id');
Schema::enableForeignKeyConstraints();
}, false);
});
}
};

View file

@ -5,10 +5,6 @@ namespace Tests\Feature;
use App\Models\User;
use App\Services\LastfmService;
use App\Services\TokenManager;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Log\Logger;
use Laravel\Sanctum\NewAccessToken;
use Laravel\Sanctum\PersonalAccessToken;
use Mockery;
@ -16,17 +12,6 @@ use Mockery\MockInterface;
class LastfmTest extends TestCase
{
public function testGetSessionKey(): void
{
/** @var Client $client */
$client = Mockery::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(__DIR__ . '../../blobs/lastfm/session-key.json')),
]);
$service = new LastfmService($client, app(Cache::class), app(Logger::class));
self::assertEquals('foo', $service->getSessionKey('bar'));
}
public function testSetSessionKey(): void
{
/** @var User $user */

View file

@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Models\Song;
use App\Models\User;
use App\Services\LastfmService;
use Mockery;
class ScrobbleTest extends TestCase
{
@ -18,14 +19,16 @@ class ScrobbleTest extends TestCase
/** @var Song $song */
$song = Song::factory()->create();
$timestamp = time();
self::mock(LastfmService::class)
->shouldReceive('scrobble')
->with($song->album->artist->name, $song->title, $timestamp, $song->album->name, $user->lastfm_session_key)
->with(
Mockery::on(static fn (Song $s) => $s->is($song)),
Mockery::on(static fn (User $u) => $u->is($user)),
100
)
->once();
$this->postAs("/api/$song->id/scrobble", ['timestamp' => $timestamp], $user)
$this->postAs("/api/$song->id/scrobble", ['timestamp' => 100], $user)
->assertNoContent();
}
}

View file

@ -8,62 +8,62 @@ use App\Models\Setting;
use App\Models\Song;
use App\Models\User;
use App\Services\UploadService;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use Mockery\MockInterface;
class UploadTest extends TestCase
{
private UploadService|MockInterface $uploadService;
private UploadedFile $file;
public function setUp(): void
{
parent::setUp();
$this->uploadService = self::mock(UploadService::class);
$this->file = UploadedFile::fake()
->createWithContent('song.mp3', file_get_contents(__DIR__ . '/../songs/full.mp3'));
}
public function testUnauthorizedPost(): void
{
Setting::set('media_path', '/media/koel');
$file = UploadedFile::fake()->create('foo.mp3', 2048);
$this->uploadService
->shouldReceive('handleUploadedFile')
->never();
$this->postAs('/api/upload', ['file' => $file])->assertForbidden();
$this->postAs('/api/upload', ['file' => $this->file])->assertForbidden();
}
/** @return array<mixed> */
public function provideUploadExceptions(): array
{
return [
[MediaPathNotSetException::class, 403],
[SongUploadFailedException::class, 400],
[MediaPathNotSetException::class, Response::HTTP_FORBIDDEN],
[SongUploadFailedException::class, Response::HTTP_BAD_REQUEST],
];
}
/** @dataProvider provideUploadExceptions */
public function testPostShouldFail(string $exceptionClass, int $statusCode): void
{
$file = UploadedFile::fake()->create('foo.mp3', 2048);
/** @var User $admin */
$admin = User::factory()->admin()->create();
$this->uploadService
->shouldReceive('handleUploadedFile')
->once()
->with($file)
->with($this->file)
->andThrow($exceptionClass);
$this->postAs('/api/upload', ['file' => $file], $admin)->assertStatus($statusCode);
$this->postAs('/api/upload', ['file' => $this->file], $admin)->assertStatus($statusCode);
}
public function testPost(): void
{
Setting::set('media_path', '/media/koel');
$file = UploadedFile::fake()->create('foo.mp3', 2048);
/** @var Song $song */
$song = Song::factory()->create();
@ -74,9 +74,9 @@ class UploadTest extends TestCase
$this->uploadService
->shouldReceive('handleUploadedFile')
->once()
->with($file)
->with($this->file)
->andReturn($song);
$this->postAs('/api/upload', ['file' => $file], $admin)->assertJsonStructure(['song', 'album']);
$this->postAs('/api/upload', ['file' => $this->file], $admin)->assertJsonStructure(['song', 'album']);
}
}

View file

@ -8,7 +8,6 @@ use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Log\Logger;
use Tests\TestCase;
class ApplicationInformationServiceTest extends TestCase
@ -22,7 +21,7 @@ class ApplicationInformationServiceTest extends TestCase
]);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$service = new ApplicationInformationService($client, app(Cache::class), app(Logger::class));
$service = new ApplicationInformationService($client, app(Cache::class));
self::assertEquals($latestVersion, $service->getLatestVersionNumber());
self::assertSame($latestVersion, cache()->get('latestKoelVersion'));

View file

@ -1,36 +0,0 @@
<?php
namespace Tests\Integration\Services;
use App\Services\ITunesService;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Log\Logger;
use Mockery;
use Tests\TestCase;
class ITunesServiceTest extends TestCase
{
public function testGetTrackUrl(): void
{
$term = 'Foo Bar';
/** @var Client $client */
$client = Mockery::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(__DIR__ . '../../../blobs/itunes/track.json')),
]);
$cache = app(Cache::class);
$logger = app(Logger::class);
$url = (new ITunesService($client, $cache, $logger))->getTrackUrl($term);
self::assertEquals(
'https://itunes.apple.com/us/album/i-remember-you/id265611220?i=265611396&uo=4&at=1000lsGu',
$url
);
self::assertNotNull(cache()->get('b57a14784d80c58a856e0df34ff0c8e2'));
}
}

View file

@ -1,111 +0,0 @@
<?php
namespace Tests\Integration\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Services\LastfmService;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Log\Logger;
use Mockery;
use Tests\TestCase;
class LastfmServiceTest extends TestCase
{
public function testGetArtistInformation(): void
{
/** @var Artist $artist */
$artist = Artist::factory()->make(['name' => 'foo']);
/** @var Client $client */
$client = Mockery::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(__DIR__ . '../../../blobs/lastfm/artist.json')),
]);
$api = new LastfmService($client, app(Cache::class), app(Logger::class));
$info = $api->getArtistInformation($artist);
self::assertEquals([
'url' => 'https://www.last.fm/music/Kamelot',
'image' => null,
'bio' => [
'summary' => 'Quisque ut nisi.',
'full' => 'Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.',
],
], $info->toArray());
self::assertNotNull(cache()->get('0aff3bc1259154f0e9db860026cda7a6'));
}
public function testGetArtistInformationForNonExistentArtist(): void
{
/** @var Artist $artist */
$artist = Artist::factory()->make();
/** @var Client $client */
$client = Mockery::mock(Client::class, [
'get' => new Response(400, [], file_get_contents(__DIR__ . '../../../blobs/lastfm/artist-notfound.json')),
]);
$api = new LastfmService($client, app(Cache::class), app(Logger::class));
self::assertNull($api->getArtistInformation($artist));
}
public function testGetAlbumInformation(): void
{
/** @var Artist $artist */
$artist = Artist::factory()->create(['name' => 'bar']);
/** @var Album $album */
$album = Album::factory()->for($artist)->create(['name' => 'foo']);
/** @var Client $client */
$client = Mockery::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(__DIR__ . '../../../blobs/lastfm/album.json')),
]);
$api = new LastfmService($client, app(Cache::class), app(Logger::class));
$info = $api->getAlbumInformation($album);
self::assertEquals([
'url' => 'https://www.last.fm/music/Kamelot/Epica',
'cover' => null,
'tracks' => [
[
'title' => 'Track 1',
'url' => 'https://foo/track1',
'length' => 100,
],
[
'title' => 'Track 2',
'url' => 'https://foo/track2',
'length' => 150,
],
],
'wiki' => [
'summary' => 'Quisque ut nisi.',
'full' => 'Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.',
],
], $info->toArray());
self::assertNotNull(cache()->get('fca889d13b3222589d7d020669cc5a38'));
}
public function testGetAlbumInformationForNonExistentAlbum(): void
{
/** @var Album $album */
$album = Album::factory()->create();
/** @var Client $client */
$client = Mockery::mock(Client::class, [
'get' => new Response(400, [], file_get_contents(__DIR__ . '../../../blobs/lastfm/album-notfound.json')),
]);
$api = new LastfmService($client, app(Cache::class), app(Logger::class));
self::assertNull($api->getAlbumInformation($album));
}
}

View file

@ -1,30 +0,0 @@
<?php
namespace Tests\Integration\Services;
use App\Services\YouTubeService;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Log\Logger;
use Mockery;
use Tests\TestCase;
class YouTubeServiceTest extends TestCase
{
public function testSearch(): void
{
$this->withoutEvents();
/** @var Client $client */
$client = Mockery::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(__DIR__ . '../../../blobs/youtube/search.json')),
]);
$api = new YouTubeService($client, app(Repository::class), app(Logger::class));
$response = $api->search('Lorem Ipsum');
self::assertEquals('Slipknot - Snuff [OFFICIAL VIDEO]', $response->items[0]->snippet->title);
self::assertNotNull(cache()->get('1492972ec5c8e6b3a9323ba719655ddb'));
}
}

View file

@ -25,7 +25,7 @@ class ScrobbleJobTest extends TestCase
$lastfm->shouldReceive('scrobble')
->once()
->with($song->artist->name, $song->title, 100, $song->album->name, $user->lastfm_session_key);
->with($song, $user, 100);
$job->handle($lastfm);
}

View file

@ -6,7 +6,6 @@ use App\Events\SongLikeToggled;
use App\Listeners\LoveTrackOnLastfm;
use App\Models\Interaction;
use App\Services\LastfmService;
use App\Values\LastfmLoveTrackParameters;
use Mockery;
use Tests\Feature\TestCase;
@ -18,17 +17,9 @@ class LoveTrackOnLastFmTest extends TestCase
$interaction = Interaction::factory()->create();
$lastfm = Mockery::mock(LastfmService::class, ['enabled' => true]);
$lastfm->shouldReceive('toggleLoveTrack')
->with(
Mockery::on(static function (LastfmLoveTrackParameters $params) use ($interaction): bool {
self::assertSame($interaction->song->title, $params->trackName);
self::assertSame($interaction->song->artist->name, $params->artistName);
return true;
}),
$interaction->user->lastfm_session_key,
$interaction->liked
);
$lastfm->shouldReceive('toggleLoveTrack')
->with($interaction->song, $interaction->user, $interaction->liked);
(new LoveTrackOnLastfm($lastfm))->handle(new SongLikeToggled($interaction));
}

View file

@ -21,8 +21,9 @@ class UpdateLastfmNowPlayingTest extends TestCase
$song = Song::factory()->create();
$lastfm = Mockery::mock(LastfmService::class, ['enabled' => true]);
$lastfm->shouldReceive('updateNowPlaying')
->with($song->artist->name, $song->title, $song->album->name, $song->length, $user->lastfm_session_key);
->with($song, $user);
(new UpdateLastfmNowPlaying($lastfm))->handle(new SongStartedPlaying($song, $user));
}

View file

@ -1,12 +1,10 @@
<?php
namespace Tests\Unit\Services;
namespace Tests\Unit\Services\ApiClients;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Log\Logger;
use Mockery;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
@ -17,26 +15,22 @@ class ApiClientTest extends TestCase
{
use WithoutMiddleware;
private Cache|LegacyMockInterface|MockInterface $cache;
private Client|LegacyMockInterface|MockInterface $client;
private Logger|LegacyMockInterface|MockInterface $logger;
private Client|LegacyMockInterface|MockInterface $wrapped;
public function setUp(): void
{
parent::setUp();
$this->client = Mockery::mock(Client::class);
$this->cache = Mockery::mock(Cache::class);
$this->logger = Mockery::mock(Logger::class);
$this->wrapped = Mockery::mock(Client::class);
}
public function testBuildUri(): void
{
$api = new ConcreteApiClient($this->client, $this->cache, $this->logger);
$api = new ConcreteApiClient($this->wrapped);
self::assertEquals('http://foo.com/get/param?key=bar', $api->buildUrl('get/param'));
self::assertEquals('http://foo.com/get/param?baz=moo&key=bar', $api->buildUrl('/get/param?baz=moo'));
self::assertEquals('http://baz.com/?key=bar', $api->buildUrl('http://baz.com/'));
self::assertEquals('https://foo.com/get/param?key=bar', $api->buildUrl('get/param'));
self::assertEquals('https://foo.com/get/param?baz=moo&key=bar', $api->buildUrl('/get/param?baz=moo'));
self::assertEquals('https://baz.com/?key=bar', $api->buildUrl('https://baz.com/'));
}
/** @return array<mixed> */
@ -58,7 +52,7 @@ class ApiClientTest extends TestCase
$method => new Response(200, [], $responseBody),
]);
$api = new ConcreteApiClient($client, $this->cache, $this->logger);
$api = new ConcreteApiClient($client);
self::assertSame((array) json_decode($responseBody), (array) $api->$method('/'));
}

View file

@ -0,0 +1,24 @@
<?php
namespace Tests\Unit\Services\ApiClients;
use App\Services\ApiClients\LastfmClient;
use GuzzleHttp\Client as GuzzleHttpClient;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Tests\TestCase;
class LastfmClientTest extends TestCase
{
public function testGetSessionKey(): void
{
$mock = new MockHandler([
new Response(200, [], file_get_contents(__DIR__ . '/../../../blobs/lastfm/session-key.json')),
]);
$client = new LastfmClient(new GuzzleHttpClient(['handler' => HandlerStack::create($mock)]));
self::assertEquals('foo', $client->getSessionKey('bar'));
}
}

View file

@ -1,14 +1,13 @@
<?php
namespace Tests\Unit\Services;
namespace Tests\Unit\Services\ApiClients;
use App\Exceptions\SpotifyIntegrationDisabledException;
use App\Services\SpotifyClient;
use App\Services\ApiClients\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;
@ -18,7 +17,6 @@ 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;
@ -34,14 +32,13 @@ class SpotifyClientTest extends TestCase
$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);
$this->client = new SpotifyClient($this->wrapped, $this->session, $this->cache);
}
public function testAccessTokenIsRetrievedFromCacheWhenApplicable(): void
@ -53,7 +50,7 @@ class SpotifyClientTest extends TestCase
$this->cache->shouldNotReceive('put');
$this->wrapped->shouldReceive('setAccessToken')->with('fake-access-token');
$this->client = new SpotifyClient($this->wrapped, $this->session, $this->cache, $this->logger);
$this->client = new SpotifyClient($this->wrapped, $this->session, $this->cache);
}
public function testCallForwarding(): void
@ -61,7 +58,7 @@ class SpotifyClientTest extends TestCase
$this->mockSetAccessToken();
$this->wrapped->shouldReceive('search')->with('foo', 'track')->andReturn('bar');
$this->client = new SpotifyClient($this->wrapped, $this->session, $this->cache, $this->logger);
$this->client = new SpotifyClient($this->wrapped, $this->session, $this->cache);
self::assertSame('bar', $this->client->search('foo', 'track'));
}
@ -74,7 +71,7 @@ class SpotifyClientTest extends TestCase
]);
self::expectException(SpotifyIntegrationDisabledException::class);
(new SpotifyClient($this->wrapped, $this->session, $this->cache, $this->logger))->search('foo', 'track');
(new SpotifyClient($this->wrapped, $this->session, $this->cache))->search('foo', 'track');
}
private function mockSetAccessToken(): void

View file

@ -2,13 +2,9 @@
namespace Tests\Unit\Services;
use App\Services\ApiClients\ITunesClient;
use App\Services\ITunesService;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Log\Logger;
use Illuminate\Cache\Repository as Cache;
use Mockery;
use Tests\TestCase;
@ -33,6 +29,7 @@ class ITunesServiceTest extends TestCase
'Foo',
'Bar',
'Baz',
'Foo Bar Baz',
'https://itunes.apple.com/bar',
'https://itunes.apple.com/bar?at=foo',
'2ce68c30758ed9496c72c36ff49c50b2',
@ -40,6 +37,7 @@ class ITunesServiceTest extends TestCase
'Foo',
'',
'Baz',
'Foo Baz',
'https://itunes.apple.com/bar?qux=qux',
'https://itunes.apple.com/bar?qux=qux&at=foo',
'cda57916eb80c2ee79b16e218bdb70d2',
@ -52,24 +50,28 @@ class ITunesServiceTest extends TestCase
string $term,
string $album,
string $artist,
string $constructedTerm,
string $trackViewUrl,
string $affiliateUrl,
string $cacheKey
): void {
config(['koel.itunes.affiliate_id' => 'foo']);
$mock = new MockHandler([
new Response(200, [], json_encode([
'resultCount' => 1,
'results' => [['trackViewUrl' => $trackViewUrl]],
])),
]);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$cache = Mockery::mock(Cache::class);
$logger = Mockery::mock(Logger::class);
$client = Mockery::mock(ITunesClient::class);
$service = new ITunesService($client, $cache, $logger);
$client->shouldReceive('get')
->with('/', [
'term' => $constructedTerm,
'media' => 'music',
'entity' => 'song',
'limit' => 1,
])
->andReturn(json_decode(json_encode([
'resultCount' => 1,
'results' => [['trackViewUrl' => $trackViewUrl]],
])));
$service = new ITunesService($client, $cache);
$cache
->shouldReceive('remember')

View file

@ -2,41 +2,197 @@
namespace Tests\Unit\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Models\User;
use App\Services\ApiClients\LastfmClient;
use App\Services\LastfmService;
use Mockery;
use Mockery\Mock;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
use Tests\TestCase;
class LastfmServiceTest extends TestCase
{
public function testBuildAuthCallParams(): void
private LastfmClient|MockInterface|LegacyMockInterface $client;
private LastfmService $service;
public function setUp(): void
{
/** @var Mock|LastfmService $lastfm */
$lastfm = Mockery::mock(LastfmService::class)->makePartial();
$lastfm->shouldReceive('getKey')->andReturn('key');
$lastfm->shouldReceive('getSecret')->andReturn('secret');
parent::setUp();
$params = [
'qux' => '安',
'bar' => 'baz',
];
config([
'koel.lastfm.key' => 'key',
'koel.lastfm.secret' => 'secret',
]);
// When I build Last.fm-compatible API parameters using the raw parameters
$builtParams = $lastfm->buildAuthCallParams($params);
$builtParamsAsString = $lastfm->buildAuthCallParams($params, true);
$this->client = Mockery::mock(LastfmClient::class);
$this->service = new LastfmService($this->client);
}
public function testGetArtistInformation(): void
{
/** @var Artist $artist */
$artist = Artist::factory()->make(['name' => 'foo']);
$this->client->shouldReceive('get')
->with('?method=artist.getInfo&autocorrect=1&artist=foo&format=json')
->once()
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/lastfm/artist.json')));
$info = $this->service->getArtistInformation($artist);
// Then I receive the Last.fm-compatible API parameters
self::assertEquals([
'api_key' => 'key',
'bar' => 'baz',
'qux' => '安',
'api_sig' => '7f21233b54edea994aa0f23cf55f18a2',
], $builtParams);
'url' => 'https://www.last.fm/music/Kamelot',
'image' => null,
'bio' => [
'summary' => 'Quisque ut nisi.',
'full' => 'Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.',
],
], $info->toArray());
}
// And the string version as well
self::assertEquals(
'api_key=key&bar=baz&qux=安&api_sig=7f21233b54edea994aa0f23cf55f18a2',
$builtParamsAsString
);
public function testGetArtistInformationForNonExistentArtist(): void
{
/** @var Artist $artist */
$artist = Artist::factory()->make(['name' => 'bar']);
$this->client->shouldReceive('get')
->with('?method=artist.getInfo&autocorrect=1&artist=bar&format=json')
->once()
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/lastfm/artist-notfound.json')));
self::assertNull($this->service->getArtistInformation($artist));
}
public function testGetAlbumInformation(): void
{
/** @var Album $album */
$album = Album::factory()->for(Artist::factory()->create(['name' => 'bar']))->create(['name' => 'foo']);
$this->client->shouldReceive('get')
->with('?method=album.getInfo&autocorrect=1&album=foo&artist=bar&format=json')
->once()
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/lastfm/album.json')));
$info = $this->service->getAlbumInformation($album);
self::assertEquals([
'url' => 'https://www.last.fm/music/Kamelot/Epica',
'cover' => null,
'tracks' => [
[
'title' => 'Track 1',
'url' => 'https://foo/track1',
'length' => 100,
],
[
'title' => 'Track 2',
'url' => 'https://foo/track2',
'length' => 150,
],
],
'wiki' => [
'summary' => 'Quisque ut nisi.',
'full' => 'Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.',
],
], $info->toArray());
}
public function testGetAlbumInformationForNonExistentAlbum(): void
{
/** @var Album $album */
$album = Album::factory()->for(Artist::factory()->create(['name' => 'bar']))->create(['name' => 'foo']);
$this->client->shouldReceive('get')
->with('?method=album.getInfo&autocorrect=1&album=foo&artist=bar&format=json')
->once()
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/lastfm/album-notfound.json')));
self::assertNull($this->service->getAlbumInformation($album));
}
public function testScrobble(): void
{
/** @var User $user */
$user = User::factory()->create([
'preferences' => [
'lastfm_session_key' => 'my_key',
],
]);
/** @var Song $song */
$song = Song::factory()->create();
$this->service->scrobble($song, $user, 100);
$this->client->shouldHaveReceived('post')
->with('/', [
'artist' => $song->artist->name,
'track' => $song->title,
'timestamp' => 100,
'sk' => 'my_key',
'method' => 'track.scrobble',
'album' => $song->album->name,
], false)
->once();
}
/** @return array<mixed> */
public function provideToggleLoveTrackData(): array
{
return [[true, 'track.love'], [false, 'track.unlove']];
}
/** @dataProvider provideToggleLoveTrackData */
public function testToggleLoveTrack(bool $love, string $method): void
{
/** @var User $user */
$user = User::factory()->create([
'preferences' => [
'lastfm_session_key' => 'my_key',
],
]);
/** @var Song $song */
$song = Song::factory()->for(Artist::factory()->create(['name' => 'foo']))->create(['title' => 'bar']);
$this->service->toggleLoveTrack($song, $user, $love);
$this->client->shouldHaveReceived('post')
->with('/', [
'artist' => 'foo',
'track' => 'bar',
'sk' => 'my_key',
'method' => $method,
], false)
->once();
}
public function testUpdateNowPlaying(): void
{
/** @var User $user */
$user = User::factory()->create([
'preferences' => [
'lastfm_session_key' => 'my_key',
],
]);
/** @var Song $song */
$song = Song::factory()->for(Artist::factory()->create(['name' => 'foo']))->create(['title' => 'bar']);
$this->service->updateNowPlaying($song, $user);
$this->client->shouldHaveReceived('post')
->with('/', [
'artist' => 'foo',
'track' => 'bar',
'duration' => $song->length,
'sk' => 'my_key',
'method' => 'track.updateNowPlaying',
'album' => $song->album->name,
], false)
->once();
}
}

View file

@ -1,14 +1,16 @@
<?php
namespace Tests\Integration\Services;
namespace Tests\Unit\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Services\LastfmService;
use App\Services\MediaInformationService;
use App\Services\MediaMetadataService;
use App\Services\MusicEncyclopedia;
use App\Values\AlbumInformation;
use App\Values\ArtistInformation;
use Illuminate\Cache\Repository as Cache;
use Mockery;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
@ -16,7 +18,7 @@ use Tests\TestCase;
class MediaInformationServiceTest extends TestCase
{
private LastfmService|MockInterface|LegacyMockInterface $lastFmService;
private MusicEncyclopedia|MockInterface|LegacyMockInterface $encyclopedia;
private MediaMetadataService|LegacyMockInterface|MockInterface $mediaMetadataService;
private MediaInformationService $mediaInformationService;
@ -24,10 +26,14 @@ class MediaInformationServiceTest extends TestCase
{
parent::setUp();
$this->lastFmService = Mockery::mock(LastfmService::class);
$this->encyclopedia = Mockery::mock(LastfmService::class);
$this->mediaMetadataService = Mockery::mock(MediaMetadataService::class);
$this->mediaInformationService = new MediaInformationService($this->lastFmService, $this->mediaMetadataService);
$this->mediaInformationService = new MediaInformationService(
$this->encyclopedia,
$this->mediaMetadataService,
app(Cache::class)
);
}
public function testGetAlbumInformation(): void
@ -36,13 +42,14 @@ class MediaInformationServiceTest extends TestCase
$album = Album::factory()->create();
$info = AlbumInformation::make();
$this->lastFmService
$this->encyclopedia
->shouldReceive('getAlbumInformation')
->once()
->with($album)
->andReturn($info);
self::assertSame($info, $this->mediaInformationService->getAlbumInformation($album));
self::assertNotNull(cache()->get('album.info.' . $album->id));
}
public function testGetAlbumInformationTriesDownloadingCover(): void
@ -51,7 +58,7 @@ class MediaInformationServiceTest extends TestCase
$album = Album::factory()->create(['cover' => '']);
$info = AlbumInformation::make();
$this->lastFmService
$this->encyclopedia
->shouldReceive('getAlbumInformation')
->once()
->with($album)
@ -70,13 +77,14 @@ class MediaInformationServiceTest extends TestCase
$artist = Artist::factory()->create();
$info = ArtistInformation::make();
$this->lastFmService
$this->encyclopedia
->shouldReceive('getArtistInformation')
->once()
->with($artist)
->andReturn($info);
self::assertSame($info, $this->mediaInformationService->getArtistInformation($artist));
self::assertNotNull(cache()->get('artist.info.' . $artist->id));
}
public function testGetArtistInformationTriesDownloadingImage(): void
@ -85,7 +93,7 @@ class MediaInformationServiceTest extends TestCase
$artist = Artist::factory()->create(['image' => '']);
$info = ArtistInformation::make();
$this->lastFmService
$this->encyclopedia
->shouldReceive('getArtistInformation')
->once()
->with($artist)

View file

@ -10,7 +10,6 @@ use App\Services\SpotifyService;
use Mockery;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
use Psr\Log\LoggerInterface;
use Tests\TestCase;
class MediaMetadataServiceTest extends TestCase
@ -26,11 +25,7 @@ class MediaMetadataServiceTest extends TestCase
$this->spotifyService = Mockery::mock(SpotifyService::class);
$this->imageWriter = Mockery::mock(ImageWriter::class);
$this->mediaMetadataService = new MediaMetadataService(
$this->spotifyService,
$this->imageWriter,
app(LoggerInterface::class)
);
$this->mediaMetadataService = new MediaMetadataService($this->spotifyService, $this->imageWriter);
}
public function testTryDownloadAlbumCover(): void

View file

@ -4,7 +4,7 @@ namespace Tests\Unit\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Services\SpotifyClient;
use App\Services\ApiClients\SpotifyClient;
use App\Services\SpotifyService;
use Mockery;
use Mockery\LegacyMockInterface;

View file

@ -0,0 +1,31 @@
<?php
namespace Tests\Unit\Services;
use App\Models\Artist;
use App\Models\Song;
use App\Services\ApiClients\YouTubeClient;
use App\Services\YouTubeService;
use Illuminate\Cache\Repository;
use Mockery;
use Tests\TestCase;
class YouTubeServiceTest extends TestCase
{
public function testSearchVideosRelatedToSong(): void
{
/** @var Song $song */
$song = Song::factory()->for(Artist::factory()->create(['name' => 'Bar']))->create(['title' => 'Foo']);
$client = Mockery::mock(YouTubeClient::class);
$client->shouldReceive('get')
->with('search?part=snippet&type=video&maxResults=10&pageToken=my-token&q=Foo+Bar')
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/youtube/search.json')));
$service = new YouTubeService($client, app(Repository::class));
$response = $service->searchVideosRelatedToSong($song, 'my-token');
self::assertEquals('Slipknot - Snuff [OFFICIAL VIDEO]', $response->items[0]->snippet->title);
self::assertNotNull(cache()->get('5becf539115b18b2df11c39adbc2bdfa'));
}
}

View file

@ -2,7 +2,7 @@
namespace Tests\Unit\Stubs;
use App\Services\ApiClient;
use App\Services\ApiClients\ApiClient;
class ConcreteApiClient extends ApiClient
{
@ -18,6 +18,6 @@ class ConcreteApiClient extends ApiClient
public function getEndpoint(): string
{
return 'http://foo.com';
return 'https://foo.com';
}
}