mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: decouple artist/album and the media information
This commit is contained in:
parent
7123d67c1a
commit
08e4953217
33 changed files with 326 additions and 321 deletions
|
@ -3,29 +3,14 @@
|
||||||
namespace App\Events;
|
namespace App\Events;
|
||||||
|
|
||||||
use App\Models\Album;
|
use App\Models\Album;
|
||||||
|
use App\Values\AlbumInformation;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class AlbumInformationFetched extends Event
|
class AlbumInformationFetched extends Event
|
||||||
{
|
{
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
private Album $album;
|
public function __construct(public Album $album, public AlbumInformation $information)
|
||||||
private array $information;
|
|
||||||
|
|
||||||
public function __construct(Album $album, array $information)
|
|
||||||
{
|
{
|
||||||
$this->album = $album;
|
|
||||||
$this->information = $information;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAlbum(): Album
|
|
||||||
{
|
|
||||||
return $this->album;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<mixed> */
|
|
||||||
public function getInformation(): array
|
|
||||||
{
|
|
||||||
return $this->information;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,29 +3,14 @@
|
||||||
namespace App\Events;
|
namespace App\Events;
|
||||||
|
|
||||||
use App\Models\Artist;
|
use App\Models\Artist;
|
||||||
|
use App\Values\ArtistInformation;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class ArtistInformationFetched
|
class ArtistInformationFetched
|
||||||
{
|
{
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
private Artist $artist;
|
public function __construct(public Artist $artist, public ArtistInformation $information)
|
||||||
private array $information;
|
|
||||||
|
|
||||||
public function __construct(Artist $artist, array $information)
|
|
||||||
{
|
{
|
||||||
$this->artist = $artist;
|
|
||||||
$this->information = $information;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getArtist(): Artist
|
|
||||||
{
|
|
||||||
return $this->artist;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<mixed> */
|
|
||||||
public function getInformation(): array
|
|
||||||
{
|
|
||||||
return $this->information;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,6 @@ class AlbumController extends Controller
|
||||||
{
|
{
|
||||||
public function show(Album $album)
|
public function show(Album $album)
|
||||||
{
|
{
|
||||||
return response()->json($this->mediaInformationService->getAlbumInformation($album));
|
return response()->json($this->mediaInformationService->getAlbumInformation($album)?->toArray() ?: []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,6 @@ class ArtistController extends Controller
|
||||||
{
|
{
|
||||||
public function show(Artist $artist)
|
public function show(Artist $artist)
|
||||||
{
|
{
|
||||||
return response()->json($this->mediaInformationService->getArtistInformation($artist));
|
return response()->json($this->mediaInformationService->getArtistInformation($artist)?->toArray() ?: []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,21 +8,19 @@ use App\Services\YouTubeService;
|
||||||
|
|
||||||
class SongController extends Controller
|
class SongController extends Controller
|
||||||
{
|
{
|
||||||
private YouTubeService $youTubeService;
|
public function __construct(
|
||||||
|
protected MediaInformationService $mediaInformationService,
|
||||||
public function __construct(MediaInformationService $mediaInformationService, YouTubeService $youTubeService)
|
private YouTubeService $youTubeService
|
||||||
{
|
) {
|
||||||
parent::__construct($mediaInformationService);
|
parent::__construct($mediaInformationService);
|
||||||
|
|
||||||
$this->youTubeService = $youTubeService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Song $song)
|
public function show(Song $song)
|
||||||
{
|
{
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'lyrics' => $song->lyrics,
|
'lyrics' => $song->lyrics,
|
||||||
'album_info' => $this->mediaInformationService->getAlbumInformation($song->album),
|
'album_info' => $this->mediaInformationService->getAlbumInformation($song->album)?->toArray() ?: [],
|
||||||
'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist),
|
'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist)?->toArray() ?: [],
|
||||||
'youtube' => $this->youTubeService->searchVideosRelatedToSong($song),
|
'youtube' => $this->youTubeService->searchVideosRelatedToSong($song),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,17 +7,13 @@ use App\Http\Resources\AlbumResource;
|
||||||
use App\Models\Album;
|
use App\Models\Album;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Repositories\AlbumRepository;
|
use App\Repositories\AlbumRepository;
|
||||||
use App\Services\MediaInformationService;
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
|
||||||
class AlbumController extends Controller
|
class AlbumController extends Controller
|
||||||
{
|
{
|
||||||
/** @param User $user */
|
/** @param User $user */
|
||||||
public function __construct(
|
public function __construct(private AlbumRepository $albumRepository, private ?Authenticatable $user)
|
||||||
private AlbumRepository $albumRepository,
|
{
|
||||||
private MediaInformationService $informationService,
|
|
||||||
private ?Authenticatable $user
|
|
||||||
) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
|
@ -32,9 +28,6 @@ class AlbumController extends Controller
|
||||||
|
|
||||||
public function show(Album $album)
|
public function show(Album $album)
|
||||||
{
|
{
|
||||||
$album = $this->albumRepository->getOne($album->id, $this->user);
|
return AlbumResource::make($this->albumRepository->getOne($album->id, $this->user));
|
||||||
$album->information = $this->informationService->getAlbumInformation($album);
|
|
||||||
|
|
||||||
return AlbumResource::make($album);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,9 +32,6 @@ class ArtistController extends Controller
|
||||||
|
|
||||||
public function show(Artist $artist)
|
public function show(Artist $artist)
|
||||||
{
|
{
|
||||||
$artist = $this->artistRepository->getOne($artist->id, $this->user);
|
return ArtistResource::make($this->artistRepository->getOne($artist->id, $this->user));
|
||||||
$artist->information = $this->informationService->getArtistInformation($artist);
|
|
||||||
|
|
||||||
return ArtistResource::make($artist);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\API\Controller;
|
||||||
|
use App\Models\Album;
|
||||||
|
use App\Services\MediaInformationService;
|
||||||
|
|
||||||
|
class FetchAlbumInformationController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Album $album, MediaInformationService $informationService)
|
||||||
|
{
|
||||||
|
return response()->json($informationService->getAlbumInformation($album));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\API\Controller;
|
||||||
|
use App\Models\Artist;
|
||||||
|
use App\Services\MediaInformationService;
|
||||||
|
|
||||||
|
class FetchArtistInformationController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Artist $artist, MediaInformationService $informationService)
|
||||||
|
{
|
||||||
|
return response()->json($informationService->getArtistInformation($artist));
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ namespace App\Http\Resources;
|
||||||
|
|
||||||
use App\Models\Album;
|
use App\Models\Album;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class AlbumResource extends JsonResource
|
class AlbumResource extends JsonResource
|
||||||
{
|
{
|
||||||
|
@ -27,7 +26,6 @@ class AlbumResource extends JsonResource
|
||||||
'length' => $this->album->length,
|
'length' => $this->album->length,
|
||||||
'play_count' => (int) $this->album->play_count,
|
'play_count' => (int) $this->album->play_count,
|
||||||
'song_count' => (int) $this->album->song_count,
|
'song_count' => (int) $this->album->song_count,
|
||||||
'info' => Arr::wrap($this->album->information),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ namespace App\Http\Resources;
|
||||||
|
|
||||||
use App\Models\Artist;
|
use App\Models\Artist;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class ArtistResource extends JsonResource
|
class ArtistResource extends JsonResource
|
||||||
{
|
{
|
||||||
|
@ -26,7 +25,6 @@ class ArtistResource extends JsonResource
|
||||||
'song_count' => (int) $this->artist->song_count,
|
'song_count' => (int) $this->artist->song_count,
|
||||||
'album_count' => (int) $this->artist->album_count,
|
'album_count' => (int) $this->artist->album_count,
|
||||||
'created_at' => $this->artist->created_at,
|
'created_at' => $this->artist->created_at,
|
||||||
'info' => Arr::wrap($this->artist->information),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,25 +8,17 @@ use Throwable;
|
||||||
|
|
||||||
class DownloadAlbumCover
|
class DownloadAlbumCover
|
||||||
{
|
{
|
||||||
private MediaMetadataService $mediaMetadataService;
|
public function __construct(private MediaMetadataService $mediaMetadataService)
|
||||||
|
|
||||||
public function __construct(MediaMetadataService $mediaMetadataService)
|
|
||||||
{
|
{
|
||||||
$this->mediaMetadataService = $mediaMetadataService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(AlbumInformationFetched $event): void
|
public function handle(AlbumInformationFetched $event): void
|
||||||
{
|
{
|
||||||
$info = $event->getInformation();
|
|
||||||
$album = $event->getAlbum();
|
|
||||||
|
|
||||||
$image = array_get($info, 'image');
|
|
||||||
|
|
||||||
// If our current album has no cover, and Last.fm has one, steal it?
|
// If our current album has no cover, and Last.fm has one, steal it?
|
||||||
if (!$album->has_cover && $image && ini_get('allow_url_fopen')) {
|
if (!$event->album->has_cover && $event->information->cover && ini_get('allow_url_fopen')) {
|
||||||
try {
|
try {
|
||||||
$this->mediaMetadataService->downloadAlbumCover($album, $image);
|
$this->mediaMetadataService->downloadAlbumCover($event->album, $event->information->cover);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,25 +8,17 @@ use Throwable;
|
||||||
|
|
||||||
class DownloadArtistImage
|
class DownloadArtistImage
|
||||||
{
|
{
|
||||||
private MediaMetadataService $mediaMetadataService;
|
public function __construct(private MediaMetadataService $mediaMetadataService)
|
||||||
|
|
||||||
public function __construct(MediaMetadataService $mediaMetadataService)
|
|
||||||
{
|
{
|
||||||
$this->mediaMetadataService = $mediaMetadataService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ArtistInformationFetched $event): void
|
public function handle(ArtistInformationFetched $event): void
|
||||||
{
|
{
|
||||||
$info = $event->getInformation();
|
|
||||||
$artist = $event->getArtist();
|
|
||||||
|
|
||||||
$image = array_get($info, 'image');
|
|
||||||
|
|
||||||
// If our artist has no image, and Last.fm has one, we steal it?
|
// If our artist has no image, and Last.fm has one, we steal it?
|
||||||
if (!$artist->has_image && $image && ini_get('allow_url_fopen')) {
|
if (!$event->artist->has_image && $event->information->image && ini_get('allow_url_fopen')) {
|
||||||
try {
|
try {
|
||||||
$this->mediaMetadataService->downloadArtistImage($artist, $image);
|
$this->mediaMetadataService->downloadArtistImage($event->artist, $event->information->image);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Values\AlbumInformation;
|
||||||
|
use App\Values\ArtistInformation;
|
||||||
use App\Values\LastfmLoveTrackParameters;
|
use App\Values\LastfmLoveTrackParameters;
|
||||||
use GuzzleHttp\Promise\Promise;
|
use GuzzleHttp\Promise\Promise;
|
||||||
use GuzzleHttp\Promise\Utils;
|
use GuzzleHttp\Promise\Utils;
|
||||||
|
@ -32,8 +34,7 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
||||||
return $this->getKey() && $this->getSecret();
|
return $this->getKey() && $this->getSecret();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<mixed>|null */
|
public function getArtistInformation(string $name): ?ArtistInformation
|
||||||
public function getArtistInformation(string $name): ?array
|
|
||||||
{
|
{
|
||||||
if (!$this->enabled()) {
|
if (!$this->enabled()) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -45,14 +46,10 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
||||||
return $this->cache->remember(
|
return $this->cache->remember(
|
||||||
md5("lastfm_artist_$name"),
|
md5("lastfm_artist_$name"),
|
||||||
now()->addWeek(),
|
now()->addWeek(),
|
||||||
function () use ($name): ?array {
|
function () use ($name): ?ArtistInformation {
|
||||||
$response = $this->get("?method=artist.getInfo&autocorrect=1&artist=$name&format=json");
|
$response = $this->get("?method=artist.getInfo&autocorrect=1&artist=$name&format=json");
|
||||||
|
|
||||||
if (!$response || !isset($response->artist)) {
|
return $response?->artist ? ArtistInformation::fromLastFmData($response->artist) : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildArtistInformation($response->artist);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
@ -62,27 +59,7 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function getAlbumInformation(string $albumName, string $artistName): ?AlbumInformation
|
||||||
* Build a Koel-usable array of artist information using the data from Last.fm.
|
|
||||||
*
|
|
||||||
* @param mixed $data
|
|
||||||
*
|
|
||||||
* @return array<mixed>
|
|
||||||
*/
|
|
||||||
private function buildArtistInformation($data): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'url' => $data->url,
|
|
||||||
'image' => count($data->image) > 3 ? $data->image[3]->{'#text'} : $data->image[0]->{'#text'},
|
|
||||||
'bio' => [
|
|
||||||
'summary' => isset($data->bio) ? $this->formatText($data->bio->summary) : '',
|
|
||||||
'full' => isset($data->bio) ? $this->formatText($data->bio->content) : '',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<mixed>|null */
|
|
||||||
public function getAlbumInformation(string $albumName, string $artistName): ?array
|
|
||||||
{
|
{
|
||||||
if (!$this->enabled()) {
|
if (!$this->enabled()) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -97,15 +74,11 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
||||||
return $this->cache->remember(
|
return $this->cache->remember(
|
||||||
$cacheKey,
|
$cacheKey,
|
||||||
now()->addWeek(),
|
now()->addWeek(),
|
||||||
function () use ($albumName, $artistName): ?array {
|
function () use ($albumName, $artistName): ?AlbumInformation {
|
||||||
$response = $this
|
$response = $this
|
||||||
->get("?method=album.getInfo&autocorrect=1&album=$albumName&artist=$artistName&format=json");
|
->get("?method=album.getInfo&autocorrect=1&album=$albumName&artist=$artistName&format=json");
|
||||||
|
|
||||||
if (!$response || !isset($response->album)) {
|
return $response?->album ? AlbumInformation::fromLastFmData($response->album) : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildAlbumInformation($response->album);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
@ -115,30 +88,6 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a Koel-usable array of album information using the data from Last.fm.
|
|
||||||
*
|
|
||||||
* @param mixed $data
|
|
||||||
*
|
|
||||||
* @return array<mixed>
|
|
||||||
*/
|
|
||||||
private function buildAlbumInformation($data): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'url' => $data->url,
|
|
||||||
'image' => count($data->image) > 3 ? $data->image[3]->{'#text'} : $data->image[0]->{'#text'},
|
|
||||||
'wiki' => [
|
|
||||||
'summary' => isset($data->wiki) ? $this->formatText($data->wiki->summary) : '',
|
|
||||||
'full' => isset($data->wiki) ? $this->formatText($data->wiki->content) : '',
|
|
||||||
],
|
|
||||||
'tracks' => array_map(static fn ($track): array => [
|
|
||||||
'title' => $track->name,
|
|
||||||
'length' => (int) $track->duration,
|
|
||||||
'url' => $track->url,
|
|
||||||
], isset($data->tracks) ? $data->tracks->track : []),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Last.fm's session key for the authenticated user using a token.
|
* Get Last.fm's session key for the authenticated user using a token.
|
||||||
*
|
*
|
||||||
|
@ -223,14 +172,11 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int|float $duration Duration of the track, in seconds
|
|
||||||
*/
|
|
||||||
public function updateNowPlaying(
|
public function updateNowPlaying(
|
||||||
string $artistName,
|
string $artistName,
|
||||||
string $trackName,
|
string $trackName,
|
||||||
string $albumName,
|
string $albumName,
|
||||||
$duration,
|
int|float $duration,
|
||||||
string $sessionKey
|
string $sessionKey
|
||||||
): void {
|
): void {
|
||||||
$params = [
|
$params = [
|
||||||
|
@ -265,7 +211,7 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
||||||
*
|
*
|
||||||
* @return array<mixed>|string
|
* @return array<mixed>|string
|
||||||
*/
|
*/
|
||||||
public function buildAuthCallParams(array $params, bool $toString = false) // @phpcs:ignore
|
public function buildAuthCallParams(array $params, bool $toString = false): array|string
|
||||||
{
|
{
|
||||||
$params['api_key'] = $this->getKey();
|
$params['api_key'] = $this->getKey();
|
||||||
ksort($params);
|
ksort($params);
|
||||||
|
@ -294,18 +240,6 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
||||||
return rtrim($query, '&');
|
return rtrim($query, '&');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Correctly format a value returned by Last.fm.
|
|
||||||
*/
|
|
||||||
protected function formatText(?string $value): string
|
|
||||||
{
|
|
||||||
if (!$value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim(str_replace('Read more on Last.fm', '', nl2br(strip_tags(html_entity_decode($value)))));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getKey(): ?string
|
public function getKey(): ?string
|
||||||
{
|
{
|
||||||
return config('koel.lastfm.key');
|
return config('koel.lastfm.key');
|
||||||
|
|
|
@ -8,6 +8,8 @@ use App\Models\Album;
|
||||||
use App\Models\Artist;
|
use App\Models\Artist;
|
||||||
use App\Repositories\AlbumRepository;
|
use App\Repositories\AlbumRepository;
|
||||||
use App\Repositories\ArtistRepository;
|
use App\Repositories\ArtistRepository;
|
||||||
|
use App\Values\AlbumInformation;
|
||||||
|
use App\Values\ArtistInformation;
|
||||||
|
|
||||||
class MediaInformationService
|
class MediaInformationService
|
||||||
{
|
{
|
||||||
|
@ -18,12 +20,7 @@ class MediaInformationService
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function getAlbumInformation(Album $album): ?AlbumInformation
|
||||||
* Get extra information about an album from Last.fm.
|
|
||||||
*
|
|
||||||
* @return array<mixed>|null the album info in an array format, or null on failure
|
|
||||||
*/
|
|
||||||
public function getAlbumInformation(Album $album): ?array
|
|
||||||
{
|
{
|
||||||
if ($album->is_unknown) {
|
if ($album->is_unknown) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -33,20 +30,14 @@ class MediaInformationService
|
||||||
|
|
||||||
if ($info) {
|
if ($info) {
|
||||||
event(new AlbumInformationFetched($album, $info));
|
event(new AlbumInformationFetched($album, $info));
|
||||||
|
|
||||||
// The album cover may have been updated.
|
// The album cover may have been updated.
|
||||||
$info['cover'] = $this->albumRepository->getOneById($album->id)->cover;
|
$info->cover = $this->albumRepository->getOneById($album->id)->cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $info;
|
return $info;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function getArtistInformation(Artist $artist): ?ArtistInformation
|
||||||
* Get extra information about an artist from Last.fm.
|
|
||||||
*
|
|
||||||
* @return array<mixed>|null the artist info in an array format, or null on failure
|
|
||||||
*/
|
|
||||||
public function getArtistInformation(Artist $artist): ?array
|
|
||||||
{
|
{
|
||||||
if ($artist->is_unknown) {
|
if ($artist->is_unknown) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -56,9 +47,8 @@ class MediaInformationService
|
||||||
|
|
||||||
if ($info) {
|
if ($info) {
|
||||||
event(new ArtistInformationFetched($artist, $info));
|
event(new ArtistInformationFetched($artist, $info));
|
||||||
|
|
||||||
// The artist image may have been updated.
|
// The artist image may have been updated.
|
||||||
$info['image'] = $this->artistRepository->getOneById($artist->id)->image;
|
$info->image = $this->artistRepository->getOneById($artist->id)->image;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $info;
|
return $info;
|
||||||
|
|
46
app/Values/AlbumInformation.php
Normal file
46
app/Values/AlbumInformation.php
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Values;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Support\Arrayable;
|
||||||
|
|
||||||
|
final class AlbumInformation implements Arrayable
|
||||||
|
{
|
||||||
|
use FormatsLastFmText;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public string $url,
|
||||||
|
public string $cover,
|
||||||
|
public array $wiki,
|
||||||
|
public array $tracks
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromLastFmData(object $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
url: $data->url,
|
||||||
|
cover: count($data->image) > 3 ? $data->image[3]->{'#text'} : $data->image[0]->{'#text'},
|
||||||
|
wiki: [
|
||||||
|
'summary' => isset($data->wiki) ? self::formatLastFmText($data->wiki->summary) : '',
|
||||||
|
'full' => isset($data->wiki) ? self::formatLastFmText($data->wiki->content) : '',
|
||||||
|
],
|
||||||
|
tracks: array_map(static fn ($track): array => [
|
||||||
|
'title' => $track->name,
|
||||||
|
'length' => (int) $track->duration,
|
||||||
|
'url' => $track->url,
|
||||||
|
], $data->tracks?->track ?? []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<mixed> */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'url' => $this->url,
|
||||||
|
'cover' => $this->cover,
|
||||||
|
'wiki' => $this->wiki,
|
||||||
|
'tracks' => $this->tracks,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
36
app/Values/ArtistInformation.php
Normal file
36
app/Values/ArtistInformation.php
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Values;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Support\Arrayable;
|
||||||
|
|
||||||
|
final class ArtistInformation implements Arrayable
|
||||||
|
{
|
||||||
|
use FormatsLastFmText;
|
||||||
|
|
||||||
|
private function __construct(public string $url, public string $image, public array $bio)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromLastFmData(object $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
url: $data->url,
|
||||||
|
image: count($data->image) > 3 ? $data->image[3]->{'#text'} : $data->image[0]->{'#text'},
|
||||||
|
bio: [
|
||||||
|
'summary' => isset($data->bio) ? self::formatLastFmText($data->bio->summary) : '',
|
||||||
|
'full' => isset($data->bio) ? self::formatLastFmText($data->bio->content) : '',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<mixed> */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'url' => $this->url,
|
||||||
|
'image' => $this->image,
|
||||||
|
'bio' => $this->bio,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
16
app/Values/FormatsLastFmText.php
Normal file
16
app/Values/FormatsLastFmText.php
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Values;
|
||||||
|
|
||||||
|
trait FormatsLastFmText
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Correctly format a value returned by Last.fm.
|
||||||
|
*/
|
||||||
|
private static function formatLastFmText(?string $value): string
|
||||||
|
{
|
||||||
|
return $value
|
||||||
|
? trim(str_replace('Read more on Last.fm', '', nl2br(strip_tags(html_entity_decode($value)))))
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,26 +12,6 @@ export default (faker: Faker): Album => {
|
||||||
id: faker.datatype.number({ min: 2 }), // avoid Unknown Album by default
|
id: faker.datatype.number({ min: 2 }), // avoid Unknown Album by default
|
||||||
name: faker.lorem.sentence(),
|
name: faker.lorem.sentence(),
|
||||||
cover: faker.image.imageUrl(),
|
cover: faker.image.imageUrl(),
|
||||||
info: {
|
|
||||||
image: faker.image.imageUrl(),
|
|
||||||
wiki: {
|
|
||||||
summary: faker.lorem.sentence(),
|
|
||||||
full: faker.lorem.paragraph()
|
|
||||||
},
|
|
||||||
tracks: [
|
|
||||||
{
|
|
||||||
title: faker.lorem.sentence(),
|
|
||||||
length: 222,
|
|
||||||
fmt_length: '3:42'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: faker.lorem.sentence(),
|
|
||||||
length: 157,
|
|
||||||
fmt_length: '2:37'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
url: faker.internet.url()
|
|
||||||
},
|
|
||||||
play_count: faker.datatype.number(),
|
play_count: faker.datatype.number(),
|
||||||
length,
|
length,
|
||||||
fmt_length: secondsToHis(length),
|
fmt_length: secondsToHis(length),
|
||||||
|
|
|
@ -8,14 +8,6 @@ export default (faker: Faker): Artist => {
|
||||||
type: 'artists',
|
type: 'artists',
|
||||||
id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default
|
id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default
|
||||||
name: faker.name.findName(),
|
name: faker.name.findName(),
|
||||||
info: {
|
|
||||||
image: faker.image.imageUrl(),
|
|
||||||
bio: {
|
|
||||||
summary: faker.lorem.sentence(),
|
|
||||||
full: faker.lorem.paragraph()
|
|
||||||
},
|
|
||||||
url: faker.internet.url()
|
|
||||||
},
|
|
||||||
image: 'foo.jpg',
|
image: 'foo.jpg',
|
||||||
play_count: faker.datatype.number(),
|
play_count: faker.datatype.number(),
|
||||||
album_count: faker.datatype.number({ max: 10 }),
|
album_count: faker.datatype.number({ max: 10 }),
|
||||||
|
|
|
@ -10,21 +10,21 @@
|
||||||
<main>
|
<main>
|
||||||
<AlbumThumbnail :entity="album"/>
|
<AlbumThumbnail :entity="album"/>
|
||||||
|
|
||||||
<template v-if="album.info">
|
<template v-if="info">
|
||||||
<div v-if="album.info.wiki?.summary" class="wiki">
|
<div v-if="info.wiki?.summary" class="wiki">
|
||||||
<div v-if="showSummary" class="summary" v-html="album.info.wiki.summary"/>
|
<div v-if="showSummary" class="summary" v-html="info.wiki.summary"/>
|
||||||
<div v-if="showFull" class="full" v-html="album.info.wiki.full"/>
|
<div v-if="showFull" class="full" v-html="info.wiki.full"/>
|
||||||
|
|
||||||
<button v-if="showSummary" class="more" data-testid="more-btn" @click.prevent="showingFullWiki = true">
|
<button v-if="showSummary" class="more" data-testid="more-btn" @click.prevent="showingFullWiki = true">
|
||||||
Full Wiki
|
Full Wiki
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TrackList v-if="album.info.tracks?.length" :album="album" data-testid="album-info-tracks"/>
|
<TrackList v-if="info.tracks?.length" :album="album" :tracks="info.tracks" data-testid="album-info-tracks"/>
|
||||||
|
|
||||||
<footer v-if="useLastfm">
|
<footer>
|
||||||
Data ©
|
Data ©
|
||||||
<a :href="album.info.url" rel="noopener" target="_blank">Last.fm</a>
|
<a :href="info.url" rel="noopener" target="_blank">Last.fm</a>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
@ -36,23 +36,27 @@ import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||||
import { useThirdPartyServices } from '@/composables'
|
import { useThirdPartyServices } from '@/composables'
|
||||||
import { songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
|
import { mediaInfoService } from '@/services/mediaInfoService'
|
||||||
|
|
||||||
const TrackList = defineAsyncComponent(() => import('./AlbumTrackList.vue'))
|
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||||
const AlbumThumbnail = defineAsyncComponent(() => import('@/components/ui/AlbumArtistThumbnail.vue'))
|
|
||||||
|
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
|
||||||
|
|
||||||
type DisplayMode = 'aside' | 'full'
|
type DisplayMode = 'aside' | 'full'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ album: Album, mode?: DisplayMode }>(), { mode: 'aside' })
|
const props = withDefaults(defineProps<{ album: Album, mode?: DisplayMode }>(), { mode: 'aside' })
|
||||||
const { album, mode } = toRefs(props)
|
const { album, mode } = toRefs(props)
|
||||||
|
|
||||||
|
const info = ref<AlbumInfo | null>(null)
|
||||||
const showingFullWiki = ref(false)
|
const showingFullWiki = ref(false)
|
||||||
|
|
||||||
const { useLastfm } = useThirdPartyServices()
|
const { useLastfm } = useThirdPartyServices()
|
||||||
|
|
||||||
/**
|
watch(album, async () => {
|
||||||
* Whenever a new album is loaded into this component, we reset the "full wiki" state.
|
showingFullWiki.value = false
|
||||||
*/
|
info.value = null
|
||||||
watch(album, () => (showingFullWiki.value = false))
|
useLastfm.value && (info.value = await mediaInfoService.fetchForAlbum(album.value))
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const showSummary = computed(() => mode.value !== 'full' && !showingFullWiki.value)
|
const showSummary = computed(() => mode.value !== 'full' && !showingFullWiki.value)
|
||||||
const showFull = computed(() => !showSummary.value)
|
const showFull = computed(() => !showSummary.value)
|
||||||
|
@ -63,5 +67,9 @@ const play = async () => playbackService.queueAndPlay(await songStore.fetchForAl
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.album-info {
|
.album-info {
|
||||||
@include artist-album-info();
|
@include artist-album-info();
|
||||||
|
|
||||||
|
.track-listing {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -3,27 +3,21 @@
|
||||||
<h1>Track Listing</h1>
|
<h1>Track Listing</h1>
|
||||||
|
|
||||||
<ul class="tracks">
|
<ul class="tracks">
|
||||||
<li
|
<li v-for="(track, index) in tracks" :key="index" data-testid="album-track-item">
|
||||||
is="vue:TrackListItem"
|
<TrackListItem :album="album" :track="track"/>
|
||||||
v-for="(track, index) in album.info?.tracks"
|
</li>
|
||||||
:key="index"
|
|
||||||
:album="album"
|
|
||||||
:track="track"
|
|
||||||
data-testid="album-track-item"
|
|
||||||
/>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, onMounted, provide, ref, toRefs } from 'vue'
|
import { onMounted, provide, ref, toRefs } from 'vue'
|
||||||
import { songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
import { SongsKey } from '@/symbols'
|
import { SongsKey } from '@/symbols'
|
||||||
|
import TrackListItem from '@/components/album/AlbumTrackListItem.vue'
|
||||||
|
|
||||||
const TrackListItem = defineAsyncComponent(() => import('./AlbumTrackListItem.vue'))
|
const props = defineProps<{ album: Album, tracks: AlbumTrack[] }>()
|
||||||
|
const { album, tracks } = toRefs(props)
|
||||||
const props = defineProps<{ album: Album }>()
|
|
||||||
const { album } = toRefs(props)
|
|
||||||
|
|
||||||
const songs = ref<Song[]>([])
|
const songs = ref<Song[]>([])
|
||||||
|
|
||||||
|
@ -33,15 +27,31 @@ onMounted(async () => songs.value = await songStore.fetchForAlbum(album.value))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
ul {
|
section {
|
||||||
counter-reset: trackCounter;
|
h1 {
|
||||||
}
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
li {
|
ul {
|
||||||
counter-increment: trackCounter;
|
counter-reset: trackCounter;
|
||||||
|
}
|
||||||
|
|
||||||
&::before {
|
li {
|
||||||
content: counter(trackCounter);
|
counter-increment: trackCounter;
|
||||||
|
display: flex;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: counter(trackCounter);
|
||||||
|
flex: 0 0 24px;
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<li
|
<div
|
||||||
|
class="track-list-item"
|
||||||
:class="{ active, available: matchedSong }"
|
:class="{ active, available: matchedSong }"
|
||||||
:title="tooltip"
|
:title="tooltip"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
@ -8,7 +9,7 @@
|
||||||
<span class="title">{{ track.title }}</span>
|
<span class="title">{{ track.title }}</span>
|
||||||
<AppleMusicButton v-if="useAppleMusic && !matchedSong" :url="iTunesUrl"/>
|
<AppleMusicButton v-if="useAppleMusic && !matchedSong" :url="iTunesUrl"/>
|
||||||
<span class="length">{{ fmtLength }}</span>
|
<span class="length">{{ fmtLength }}</span>
|
||||||
</li>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
@ -21,8 +22,8 @@ import { SongsKey } from '@/symbols'
|
||||||
|
|
||||||
const AppleMusicButton = defineAsyncComponent(() => import('@/components/ui/AppleMusicButton.vue'))
|
const AppleMusicButton = defineAsyncComponent(() => import('@/components/ui/AppleMusicButton.vue'))
|
||||||
|
|
||||||
const props = defineProps<{ album: Album, track: AlbumTrack, songs: Song[] }>()
|
const props = defineProps<{ album: Album, track: AlbumTrack }>()
|
||||||
const { album, track, songs } = toRefs(props)
|
const { album, track } = toRefs(props)
|
||||||
|
|
||||||
const { useAppleMusic } = useThirdPartyServices()
|
const { useAppleMusic } = useThirdPartyServices()
|
||||||
|
|
||||||
|
@ -47,15 +48,34 @@ const play = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
li {
|
.track-list-item {
|
||||||
span.title {
|
display: flex;
|
||||||
margin-right: 5px;
|
flex: 1;
|
||||||
}
|
gap: 4px;
|
||||||
|
|
||||||
&:focus, &.active {
|
&:focus, &.active {
|
||||||
span.title {
|
span.title {
|
||||||
color: var(--color-highlight);
|
color: var(--color-highlight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.length {
|
||||||
|
flex: 0 0 44px;
|
||||||
|
text-align: right;
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.available {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-highlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -7,22 +7,22 @@
|
||||||
</button>
|
</button>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<main v-if="artist.info">
|
<main>
|
||||||
<ArtistThumbnail :entity="artist"/>
|
<ArtistThumbnail :entity="artist"/>
|
||||||
|
|
||||||
<template v-if="artist.info">
|
<template v-if="info">
|
||||||
<div v-if="artist.info.bio?.summary" class="bio">
|
<div v-if="info.bio?.summary" class="bio">
|
||||||
<div v-if="showSummary" class="summary" v-html="artist.info.bio.summary"/>
|
<div v-if="showSummary" class="summary" v-html="info.bio.summary"/>
|
||||||
<div v-if="showFull" class="full" v-html="artist.info.bio.full"/>
|
<div v-if="showFull" class="full" v-html="info.bio.full"/>
|
||||||
|
|
||||||
<button v-show="showSummary" class="more" data-testid="more-btn" @click.prevent="showingFullBio = true">
|
<button v-show="showSummary" class="more" data-testid="more-btn" @click.prevent="showingFullBio = true">
|
||||||
Full Bio
|
Full Bio
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer v-if="useLastfm">
|
<footer>
|
||||||
Data ©
|
Data ©
|
||||||
<a :href="artist.info.url" rel="openener" target="_blank">Last.fm</a>
|
<a :href="info.url" rel="openener" target="_blank">Last.fm</a>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
@ -30,23 +30,29 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
import { computed, ref, toRefs, watch } from 'vue'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { useThirdPartyServices } from '@/composables'
|
import { useThirdPartyServices } from '@/composables'
|
||||||
import { songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
|
import { mediaInfoService } from '@/services/mediaInfoService'
|
||||||
|
|
||||||
|
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||||
|
|
||||||
type DisplayMode = 'aside' | 'full'
|
type DisplayMode = 'aside' | 'full'
|
||||||
|
|
||||||
const ArtistThumbnail = defineAsyncComponent(() => import('@/components/ui/AlbumArtistThumbnail.vue'))
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ artist: Artist, mode?: DisplayMode }>(), { mode: 'aside' })
|
const props = withDefaults(defineProps<{ artist: Artist, mode?: DisplayMode }>(), { mode: 'aside' })
|
||||||
const { artist, mode } = toRefs(props)
|
const { artist, mode } = toRefs(props)
|
||||||
|
|
||||||
const showingFullBio = ref(false)
|
|
||||||
|
|
||||||
const { useLastfm } = useThirdPartyServices()
|
const { useLastfm } = useThirdPartyServices()
|
||||||
|
|
||||||
watch(artist, () => (showingFullBio.value = false))
|
const info = ref<ArtistInfo | null>(null)
|
||||||
|
const showingFullBio = ref(false)
|
||||||
|
|
||||||
|
watch(artist, async () => {
|
||||||
|
showingFullBio.value = false
|
||||||
|
info.value = null
|
||||||
|
useLastfm.value && (info.value = await mediaInfoService.fetchForArtist(artist.value))
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const showSummary = computed(() => mode.value !== 'full' && !showingFullBio.value)
|
const showSummary = computed(() => mode.value !== 'full' && !showingFullBio.value)
|
||||||
const showFull = computed(() => !showSummary.value)
|
const showFull = computed(() => !showSummary.value)
|
||||||
|
|
|
@ -46,7 +46,7 @@
|
||||||
<SongList ref="songList" @press:enter="onPressEnter"/>
|
<SongList ref="songList" @press:enter="onPressEnter"/>
|
||||||
|
|
||||||
<section v-if="useLastfm && showingInfo" class="info-wrapper">
|
<section v-if="useLastfm && showingInfo" class="info-wrapper">
|
||||||
<CloseModalBtn @click="showingInfo = false"/>
|
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<AlbumInfo :album="album" mode="full"/>
|
<AlbumInfo :album="album" mode="full"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -48,7 +48,7 @@
|
||||||
<SongList ref="songList" @press:enter="onPressEnter"/>
|
<SongList ref="songList" @press:enter="onPressEnter"/>
|
||||||
|
|
||||||
<section class="info-wrapper" v-if="useLastfm && showingInfo">
|
<section class="info-wrapper" v-if="useLastfm && showingInfo">
|
||||||
<CloseModalBtn @click="showingInfo = false"/>
|
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<ArtistInfo :artist="artist" mode="full"/>
|
<ArtistInfo :artist="artist" mode="full"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -12,6 +12,10 @@ export const Cache = {
|
||||||
return this.hit(this.normalizeKey(key))
|
return this.hit(this.normalizeKey(key))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get<T> (key: any) {
|
||||||
|
return this.storage.get(this.normalizeKey(key))!.value as T
|
||||||
|
},
|
||||||
|
|
||||||
set (key: any, value: any) {
|
set (key: any, value: any) {
|
||||||
this.storage.set(this.normalizeKey(key), {
|
this.storage.set(this.normalizeKey(key), {
|
||||||
value,
|
value,
|
||||||
|
@ -45,6 +49,6 @@ export const Cache = {
|
||||||
key = this.normalizeKey(key)
|
key = this.normalizeKey(key)
|
||||||
|
|
||||||
this.hit(key) || this.set(key, await fetcher())
|
this.hit(key) || this.set(key, await fetcher())
|
||||||
return this.storage.get(key)!.value
|
return this.get<T>(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
23
resources/assets/js/services/mediaInfoService.ts
Normal file
23
resources/assets/js/services/mediaInfoService.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { Cache, httpService } from '@/services'
|
||||||
|
|
||||||
|
export const mediaInfoService = {
|
||||||
|
async fetchForArtist (artist: Artist) {
|
||||||
|
const cacheKey = ['artist.info', artist.id]
|
||||||
|
if (Cache.has(cacheKey)) return Cache.get<ArtistInfo>(cacheKey)
|
||||||
|
|
||||||
|
const info = await httpService.get<ArtistInfo | null>(`artists/${artist.id}/information`)
|
||||||
|
info && Cache.set(cacheKey, info)
|
||||||
|
|
||||||
|
return info
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchForAlbum (album: Album) {
|
||||||
|
const cacheKey = ['album.info', album.id]
|
||||||
|
if (Cache.has(cacheKey)) return Cache.get<AlbumInfo>(cacheKey)
|
||||||
|
|
||||||
|
const info = await httpService.get<AlbumInfo | null>(`albums/${album.id}/information`)
|
||||||
|
info && Cache.set(cacheKey, info)
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
}
|
|
@ -28,9 +28,13 @@ export const albumStore = {
|
||||||
* @param {Album} album The album object
|
* @param {Album} album The album object
|
||||||
* @param {string} cover The content data string of the cover
|
* @param {string} cover The content data string of the cover
|
||||||
*/
|
*/
|
||||||
uploadCover: async (album: Album, cover: string) => {
|
async uploadCover (album: Album, cover: string) {
|
||||||
album.cover = (await httpService.put<{ coverUrl: string }>(`album/${album.id}/cover`, { cover })).coverUrl
|
album.cover = (await httpService.put<{ coverUrl: string }>(`album/${album.id}/cover`, { cover })).coverUrl
|
||||||
songStore.byAlbum(album).forEach(song => song.album_cover = album.cover)
|
songStore.byAlbum(album).forEach(song => song.album_cover = album.cover)
|
||||||
|
|
||||||
|
// sync to vault
|
||||||
|
this.byId(album.id).cover = album.cover
|
||||||
|
|
||||||
return album.cover
|
return album.cover
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -34,9 +34,12 @@ export const artistStore = {
|
||||||
return !this.isVarious(artist) && !this.isUnknown(artist)
|
return !this.isVarious(artist) && !this.isUnknown(artist)
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadImage: async (artist: Artist, image: string) => {
|
async uploadImage (artist: Artist, image: string) {
|
||||||
const { imageUrl } = await httpService.put<{ imageUrl: string }>(`artist/${artist.id}/image`, { image })
|
artist.image = (await httpService.put<{ imageUrl: string }>(`artist/${artist.id}/image`, { image })).imageUrl
|
||||||
artist.image = imageUrl
|
|
||||||
|
// sync to vault
|
||||||
|
this.byId(artist.id).image = artist.image
|
||||||
|
|
||||||
return artist.image
|
return artist.image
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
3
resources/assets/js/types.d.ts
vendored
3
resources/assets/js/types.d.ts
vendored
|
@ -118,7 +118,6 @@ interface FileSystemEntry {
|
||||||
interface AlbumTrack {
|
interface AlbumTrack {
|
||||||
readonly title: string
|
readonly title: string
|
||||||
readonly length: number
|
readonly length: number
|
||||||
fmt_length: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlbumInfo {
|
interface AlbumInfo {
|
||||||
|
@ -145,7 +144,6 @@ interface Artist {
|
||||||
readonly id: number
|
readonly id: number
|
||||||
name: string
|
name: string
|
||||||
image: string | null
|
image: string | null
|
||||||
info: ArtistInfo | null
|
|
||||||
play_count: number
|
play_count: number
|
||||||
album_count: number
|
album_count: number
|
||||||
song_count: number
|
song_count: number
|
||||||
|
@ -162,7 +160,6 @@ interface Album {
|
||||||
name: string
|
name: string
|
||||||
cover: string
|
cover: string
|
||||||
thumbnail?: string | null
|
thumbnail?: string | null
|
||||||
info: AlbumInfo | null
|
|
||||||
play_count: number
|
play_count: number
|
||||||
song_count: number
|
song_count: number
|
||||||
length: number
|
length: number
|
||||||
|
|
|
@ -151,6 +151,16 @@
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.close-modal {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.close-modal {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,6 +173,7 @@
|
||||||
|
|
||||||
&.name {
|
&.name {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
|
@ -180,7 +191,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.bio {
|
.bio {
|
||||||
margin-top: 16px;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more {
|
.more {
|
||||||
|
@ -203,56 +214,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki {
|
.wiki {
|
||||||
margin-top: 16px;
|
margin: 16px 0;
|
||||||
}
|
|
||||||
|
|
||||||
.track-listing {
|
|
||||||
margin-top: 16px;
|
|
||||||
|
|
||||||
ul {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
margin-bottom: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px;
|
|
||||||
|
|
||||||
&:nth-child(even) {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
// the track counter
|
|
||||||
flex: 0 0 24px;
|
|
||||||
opacity: .5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.length {
|
|
||||||
flex: 0 0 44px;
|
|
||||||
text-align: right;
|
|
||||||
opacity: .5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.available {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
|
|
|
@ -10,6 +10,8 @@ use App\Http\Controllers\V6\API\ArtistSongController;
|
||||||
use App\Http\Controllers\V6\API\DataController;
|
use App\Http\Controllers\V6\API\DataController;
|
||||||
use App\Http\Controllers\V6\API\ExcerptSearchController;
|
use App\Http\Controllers\V6\API\ExcerptSearchController;
|
||||||
use App\Http\Controllers\V6\API\FavoriteController;
|
use App\Http\Controllers\V6\API\FavoriteController;
|
||||||
|
use App\Http\Controllers\V6\API\FetchAlbumInformationController;
|
||||||
|
use App\Http\Controllers\V6\API\FetchArtistInformationController;
|
||||||
use App\Http\Controllers\V6\API\OverviewController;
|
use App\Http\Controllers\V6\API\OverviewController;
|
||||||
use App\Http\Controllers\V6\API\PlayCountController;
|
use App\Http\Controllers\V6\API\PlayCountController;
|
||||||
use App\Http\Controllers\V6\API\PlaylistSongController;
|
use App\Http\Controllers\V6\API\PlaylistSongController;
|
||||||
|
@ -30,12 +32,16 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
||||||
Route::apiResource('artists', ArtistController::class);
|
Route::apiResource('artists', ArtistController::class);
|
||||||
Route::apiResource('artists.songs', ArtistSongController::class);
|
Route::apiResource('artists.songs', ArtistSongController::class);
|
||||||
|
|
||||||
|
Route::get('albums/{album}/information', FetchAlbumInformationController::class);
|
||||||
|
Route::get('artists/{artist}/information', FetchArtistInformationController::class);
|
||||||
|
|
||||||
Route::apiResource('playlists', PlaylistController::class);
|
Route::apiResource('playlists', PlaylistController::class);
|
||||||
Route::apiResource('playlists.songs', PlaylistSongController::class);
|
Route::apiResource('playlists.songs', PlaylistSongController::class);
|
||||||
Route::post('playlists/{playlist}/songs', [PlaylistSongController::class, 'add']);
|
Route::post('playlists/{playlist}/songs', [PlaylistSongController::class, 'add']);
|
||||||
Route::delete('playlists/{playlist}/songs', [PlaylistSongController::class, 'remove']);
|
Route::delete('playlists/{playlist}/songs', [PlaylistSongController::class, 'remove']);
|
||||||
|
|
||||||
Route::apiResource('songs', SongController::class);
|
Route::apiResource('songs', SongController::class);
|
||||||
|
|
||||||
Route::get('users', [UserController::class, 'index']);
|
Route::get('users', [UserController::class, 'index']);
|
||||||
|
|
||||||
Route::get('search', ExcerptSearchController::class);
|
Route::get('search', ExcerptSearchController::class);
|
||||||
|
|
Loading…
Reference in a new issue