mirror of
https://github.com/koel/koel
synced 2024-11-24 13:13:05 +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;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Values\AlbumInformation;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class AlbumInformationFetched extends Event
|
||||
{
|
||||
use SerializesModels;
|
||||
|
||||
private Album $album;
|
||||
private array $information;
|
||||
|
||||
public function __construct(Album $album, array $information)
|
||||
public function __construct(public Album $album, public AlbumInformation $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;
|
||||
|
||||
use App\Models\Artist;
|
||||
use App\Values\ArtistInformation;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ArtistInformationFetched
|
||||
{
|
||||
use SerializesModels;
|
||||
|
||||
private Artist $artist;
|
||||
private array $information;
|
||||
|
||||
public function __construct(Artist $artist, array $information)
|
||||
public function __construct(public Artist $artist, public ArtistInformation $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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
{
|
||||
private YouTubeService $youTubeService;
|
||||
|
||||
public function __construct(MediaInformationService $mediaInformationService, YouTubeService $youTubeService)
|
||||
{
|
||||
public function __construct(
|
||||
protected MediaInformationService $mediaInformationService,
|
||||
private YouTubeService $youTubeService
|
||||
) {
|
||||
parent::__construct($mediaInformationService);
|
||||
|
||||
$this->youTubeService = $youTubeService;
|
||||
}
|
||||
|
||||
public function show(Song $song)
|
||||
{
|
||||
return response()->json([
|
||||
'lyrics' => $song->lyrics,
|
||||
'album_info' => $this->mediaInformationService->getAlbumInformation($song->album),
|
||||
'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist),
|
||||
'album_info' => $this->mediaInformationService->getAlbumInformation($song->album)?->toArray() ?: [],
|
||||
'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist)?->toArray() ?: [],
|
||||
'youtube' => $this->youTubeService->searchVideosRelatedToSong($song),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -7,17 +7,13 @@ use App\Http\Resources\AlbumResource;
|
|||
use App\Models\Album;
|
||||
use App\Models\User;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Services\MediaInformationService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class AlbumController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(
|
||||
private AlbumRepository $albumRepository,
|
||||
private MediaInformationService $informationService,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
public function __construct(private AlbumRepository $albumRepository, private ?Authenticatable $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function index()
|
||||
|
@ -32,9 +28,6 @@ class AlbumController extends Controller
|
|||
|
||||
public function show(Album $album)
|
||||
{
|
||||
$album = $this->albumRepository->getOne($album->id, $this->user);
|
||||
$album->information = $this->informationService->getAlbumInformation($album);
|
||||
|
||||
return AlbumResource::make($album);
|
||||
return AlbumResource::make($this->albumRepository->getOne($album->id, $this->user));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,9 +32,6 @@ class ArtistController extends Controller
|
|||
|
||||
public function show(Artist $artist)
|
||||
{
|
||||
$artist = $this->artistRepository->getOne($artist->id, $this->user);
|
||||
$artist->information = $this->informationService->getArtistInformation($artist);
|
||||
|
||||
return ArtistResource::make($artist);
|
||||
return ArtistResource::make($this->artistRepository->getOne($artist->id, $this->user));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class AlbumResource extends JsonResource
|
||||
{
|
||||
|
@ -27,7 +26,6 @@ class AlbumResource extends JsonResource
|
|||
'length' => $this->album->length,
|
||||
'play_count' => (int) $this->album->play_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 Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ArtistResource extends JsonResource
|
||||
{
|
||||
|
@ -26,7 +25,6 @@ class ArtistResource extends JsonResource
|
|||
'song_count' => (int) $this->artist->song_count,
|
||||
'album_count' => (int) $this->artist->album_count,
|
||||
'created_at' => $this->artist->created_at,
|
||||
'info' => Arr::wrap($this->artist->information),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,25 +8,17 @@ use Throwable;
|
|||
|
||||
class DownloadAlbumCover
|
||||
{
|
||||
private MediaMetadataService $mediaMetadataService;
|
||||
|
||||
public function __construct(MediaMetadataService $mediaMetadataService)
|
||||
public function __construct(private MediaMetadataService $mediaMetadataService)
|
||||
{
|
||||
$this->mediaMetadataService = $mediaMetadataService;
|
||||
}
|
||||
|
||||
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 (!$album->has_cover && $image && ini_get('allow_url_fopen')) {
|
||||
if (!$event->album->has_cover && $event->information->cover && ini_get('allow_url_fopen')) {
|
||||
try {
|
||||
$this->mediaMetadataService->downloadAlbumCover($album, $image);
|
||||
} catch (Throwable $e) {
|
||||
$this->mediaMetadataService->downloadAlbumCover($event->album, $event->information->cover);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,25 +8,17 @@ use Throwable;
|
|||
|
||||
class DownloadArtistImage
|
||||
{
|
||||
private MediaMetadataService $mediaMetadataService;
|
||||
|
||||
public function __construct(MediaMetadataService $mediaMetadataService)
|
||||
public function __construct(private MediaMetadataService $mediaMetadataService)
|
||||
{
|
||||
$this->mediaMetadataService = $mediaMetadataService;
|
||||
}
|
||||
|
||||
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 (!$artist->has_image && $image && ini_get('allow_url_fopen')) {
|
||||
if (!$event->artist->has_image && $event->information->image && ini_get('allow_url_fopen')) {
|
||||
try {
|
||||
$this->mediaMetadataService->downloadArtistImage($artist, $image);
|
||||
} catch (Throwable $e) {
|
||||
$this->mediaMetadataService->downloadArtistImage($event->artist, $event->information->image);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Values\AlbumInformation;
|
||||
use App\Values\ArtistInformation;
|
||||
use App\Values\LastfmLoveTrackParameters;
|
||||
use GuzzleHttp\Promise\Promise;
|
||||
use GuzzleHttp\Promise\Utils;
|
||||
|
@ -32,8 +34,7 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
|||
return $this->getKey() && $this->getSecret();
|
||||
}
|
||||
|
||||
/** @return array<mixed>|null */
|
||||
public function getArtistInformation(string $name): ?array
|
||||
public function getArtistInformation(string $name): ?ArtistInformation
|
||||
{
|
||||
if (!$this->enabled()) {
|
||||
return null;
|
||||
|
@ -45,14 +46,10 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
|||
return $this->cache->remember(
|
||||
md5("lastfm_artist_$name"),
|
||||
now()->addWeek(),
|
||||
function () use ($name): ?array {
|
||||
function () use ($name): ?ArtistInformation {
|
||||
$response = $this->get("?method=artist.getInfo&autocorrect=1&artist=$name&format=json");
|
||||
|
||||
if (!$response || !isset($response->artist)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildArtistInformation($response->artist);
|
||||
return $response?->artist ? ArtistInformation::fromLastFmData($response->artist) : null;
|
||||
}
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
|
@ -62,27 +59,7 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
public function getAlbumInformation(string $albumName, string $artistName): ?AlbumInformation
|
||||
{
|
||||
if (!$this->enabled()) {
|
||||
return null;
|
||||
|
@ -97,15 +74,11 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
|||
return $this->cache->remember(
|
||||
$cacheKey,
|
||||
now()->addWeek(),
|
||||
function () use ($albumName, $artistName): ?array {
|
||||
function () use ($albumName, $artistName): ?AlbumInformation {
|
||||
$response = $this
|
||||
->get("?method=album.getInfo&autocorrect=1&album=$albumName&artist=$artistName&format=json");
|
||||
|
||||
if (!$response || !isset($response->album)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildAlbumInformation($response->album);
|
||||
return $response?->album ? AlbumInformation::fromLastFmData($response->album) : null;
|
||||
}
|
||||
);
|
||||
} 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.
|
||||
*
|
||||
|
@ -223,14 +172,11 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|float $duration Duration of the track, in seconds
|
||||
*/
|
||||
public function updateNowPlaying(
|
||||
string $artistName,
|
||||
string $trackName,
|
||||
string $albumName,
|
||||
$duration,
|
||||
int|float $duration,
|
||||
string $sessionKey
|
||||
): void {
|
||||
$params = [
|
||||
|
@ -265,7 +211,7 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
|||
*
|
||||
* @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();
|
||||
ksort($params);
|
||||
|
@ -294,18 +240,6 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface
|
|||
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
|
||||
{
|
||||
return config('koel.lastfm.key');
|
||||
|
|
|
@ -8,6 +8,8 @@ use App\Models\Album;
|
|||
use App\Models\Artist;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Repositories\ArtistRepository;
|
||||
use App\Values\AlbumInformation;
|
||||
use App\Values\ArtistInformation;
|
||||
|
||||
class MediaInformationService
|
||||
{
|
||||
|
@ -18,12 +20,7 @@ class MediaInformationService
|
|||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
public function getAlbumInformation(Album $album): ?AlbumInformation
|
||||
{
|
||||
if ($album->is_unknown) {
|
||||
return null;
|
||||
|
@ -33,20 +30,14 @@ class MediaInformationService
|
|||
|
||||
if ($info) {
|
||||
event(new AlbumInformationFetched($album, $info));
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
public function getArtistInformation(Artist $artist): ?ArtistInformation
|
||||
{
|
||||
if ($artist->is_unknown) {
|
||||
return null;
|
||||
|
@ -56,9 +47,8 @@ class MediaInformationService
|
|||
|
||||
if ($info) {
|
||||
event(new ArtistInformationFetched($artist, $info));
|
||||
|
||||
// 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;
|
||||
|
|
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
|
||||
name: faker.lorem.sentence(),
|
||||
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(),
|
||||
length,
|
||||
fmt_length: secondsToHis(length),
|
||||
|
|
|
@ -8,14 +8,6 @@ export default (faker: Faker): Artist => {
|
|||
type: 'artists',
|
||||
id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default
|
||||
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',
|
||||
play_count: faker.datatype.number(),
|
||||
album_count: faker.datatype.number({ max: 10 }),
|
||||
|
|
|
@ -10,21 +10,21 @@
|
|||
<main>
|
||||
<AlbumThumbnail :entity="album"/>
|
||||
|
||||
<template v-if="album.info">
|
||||
<div v-if="album.info.wiki?.summary" class="wiki">
|
||||
<div v-if="showSummary" class="summary" v-html="album.info.wiki.summary"/>
|
||||
<div v-if="showFull" class="full" v-html="album.info.wiki.full"/>
|
||||
<template v-if="info">
|
||||
<div v-if="info.wiki?.summary" class="wiki">
|
||||
<div v-if="showSummary" class="summary" v-html="info.wiki.summary"/>
|
||||
<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">
|
||||
Full Wiki
|
||||
</button>
|
||||
</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 ©
|
||||
<a :href="album.info.url" rel="noopener" target="_blank">Last.fm</a>
|
||||
<a :href="info.url" rel="noopener" target="_blank">Last.fm</a>
|
||||
</footer>
|
||||
</template>
|
||||
</main>
|
||||
|
@ -36,23 +36,27 @@ import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
|||
import { useThirdPartyServices } from '@/composables'
|
||||
import { songStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
import { mediaInfoService } from '@/services/mediaInfoService'
|
||||
|
||||
const TrackList = defineAsyncComponent(() => import('./AlbumTrackList.vue'))
|
||||
const AlbumThumbnail = defineAsyncComponent(() => import('@/components/ui/AlbumArtistThumbnail.vue'))
|
||||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
|
||||
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
|
||||
|
||||
type DisplayMode = 'aside' | 'full'
|
||||
|
||||
const props = withDefaults(defineProps<{ album: Album, mode?: DisplayMode }>(), { mode: 'aside' })
|
||||
const { album, mode } = toRefs(props)
|
||||
|
||||
const info = ref<AlbumInfo | null>(null)
|
||||
const showingFullWiki = ref(false)
|
||||
|
||||
const { useLastfm } = useThirdPartyServices()
|
||||
|
||||
/**
|
||||
* Whenever a new album is loaded into this component, we reset the "full wiki" state.
|
||||
*/
|
||||
watch(album, () => (showingFullWiki.value = false))
|
||||
watch(album, async () => {
|
||||
showingFullWiki.value = false
|
||||
info.value = null
|
||||
useLastfm.value && (info.value = await mediaInfoService.fetchForAlbum(album.value))
|
||||
}, { immediate: true })
|
||||
|
||||
const showSummary = computed(() => mode.value !== 'full' && !showingFullWiki.value)
|
||||
const showFull = computed(() => !showSummary.value)
|
||||
|
@ -63,5 +67,9 @@ const play = async () => playbackService.queueAndPlay(await songStore.fetchForAl
|
|||
<style lang="scss">
|
||||
.album-info {
|
||||
@include artist-album-info();
|
||||
|
||||
.track-listing {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,27 +3,21 @@
|
|||
<h1>Track Listing</h1>
|
||||
|
||||
<ul class="tracks">
|
||||
<li
|
||||
is="vue:TrackListItem"
|
||||
v-for="(track, index) in album.info?.tracks"
|
||||
:key="index"
|
||||
:album="album"
|
||||
:track="track"
|
||||
data-testid="album-track-item"
|
||||
/>
|
||||
<li v-for="(track, index) in tracks" :key="index" data-testid="album-track-item">
|
||||
<TrackListItem :album="album" :track="track"/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, provide, ref, toRefs } from 'vue'
|
||||
import { onMounted, provide, ref, toRefs } from 'vue'
|
||||
import { songStore } from '@/stores'
|
||||
import { SongsKey } from '@/symbols'
|
||||
import TrackListItem from '@/components/album/AlbumTrackListItem.vue'
|
||||
|
||||
const TrackListItem = defineAsyncComponent(() => import('./AlbumTrackListItem.vue'))
|
||||
|
||||
const props = defineProps<{ album: Album }>()
|
||||
const { album } = toRefs(props)
|
||||
const props = defineProps<{ album: Album, tracks: AlbumTrack[] }>()
|
||||
const { album, tracks } = toRefs(props)
|
||||
|
||||
const songs = ref<Song[]>([])
|
||||
|
||||
|
@ -33,15 +27,31 @@ onMounted(async () => songs.value = await songStore.fetchForAlbum(album.value))
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ul {
|
||||
counter-reset: trackCounter;
|
||||
}
|
||||
section {
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
li {
|
||||
counter-increment: trackCounter;
|
||||
ul {
|
||||
counter-reset: trackCounter;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: counter(trackCounter);
|
||||
li {
|
||||
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>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<li
|
||||
<div
|
||||
class="track-list-item"
|
||||
:class="{ active, available: matchedSong }"
|
||||
:title="tooltip"
|
||||
tabindex="0"
|
||||
|
@ -8,7 +9,7 @@
|
|||
<span class="title">{{ track.title }}</span>
|
||||
<AppleMusicButton v-if="useAppleMusic && !matchedSong" :url="iTunesUrl"/>
|
||||
<span class="length">{{ fmtLength }}</span>
|
||||
</li>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@ -21,8 +22,8 @@ import { SongsKey } from '@/symbols'
|
|||
|
||||
const AppleMusicButton = defineAsyncComponent(() => import('@/components/ui/AppleMusicButton.vue'))
|
||||
|
||||
const props = defineProps<{ album: Album, track: AlbumTrack, songs: Song[] }>()
|
||||
const { album, track, songs } = toRefs(props)
|
||||
const props = defineProps<{ album: Album, track: AlbumTrack }>()
|
||||
const { album, track } = toRefs(props)
|
||||
|
||||
const { useAppleMusic } = useThirdPartyServices()
|
||||
|
||||
|
@ -47,15 +48,34 @@ const play = () => {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
li {
|
||||
span.title {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.track-list-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
|
||||
&:focus, &.active {
|
||||
span.title {
|
||||
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>
|
||||
|
|
|
@ -7,22 +7,22 @@
|
|||
</button>
|
||||
</h1>
|
||||
|
||||
<main v-if="artist.info">
|
||||
<main>
|
||||
<ArtistThumbnail :entity="artist"/>
|
||||
|
||||
<template v-if="artist.info">
|
||||
<div v-if="artist.info.bio?.summary" class="bio">
|
||||
<div v-if="showSummary" class="summary" v-html="artist.info.bio.summary"/>
|
||||
<div v-if="showFull" class="full" v-html="artist.info.bio.full"/>
|
||||
<template v-if="info">
|
||||
<div v-if="info.bio?.summary" class="bio">
|
||||
<div v-if="showSummary" class="summary" v-html="info.bio.summary"/>
|
||||
<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">
|
||||
Full Bio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer v-if="useLastfm">
|
||||
<footer>
|
||||
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>
|
||||
</template>
|
||||
</main>
|
||||
|
@ -30,23 +30,29 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||
import { computed, ref, toRefs, watch } from 'vue'
|
||||
import { playbackService } from '@/services'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
import { songStore } from '@/stores'
|
||||
import { mediaInfoService } from '@/services/mediaInfoService'
|
||||
|
||||
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
|
||||
type DisplayMode = 'aside' | 'full'
|
||||
|
||||
const ArtistThumbnail = defineAsyncComponent(() => import('@/components/ui/AlbumArtistThumbnail.vue'))
|
||||
|
||||
const props = withDefaults(defineProps<{ artist: Artist, mode?: DisplayMode }>(), { mode: 'aside' })
|
||||
const { artist, mode } = toRefs(props)
|
||||
|
||||
const showingFullBio = ref(false)
|
||||
|
||||
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 showFull = computed(() => !showSummary.value)
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
<SongList ref="songList" @press:enter="onPressEnter"/>
|
||||
|
||||
<section v-if="useLastfm && showingInfo" class="info-wrapper">
|
||||
<CloseModalBtn @click="showingInfo = false"/>
|
||||
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
|
||||
<div class="inner">
|
||||
<AlbumInfo :album="album" mode="full"/>
|
||||
</div>
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
<SongList ref="songList" @press:enter="onPressEnter"/>
|
||||
|
||||
<section class="info-wrapper" v-if="useLastfm && showingInfo">
|
||||
<CloseModalBtn @click="showingInfo = false"/>
|
||||
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
|
||||
<div class="inner">
|
||||
<ArtistInfo :artist="artist" mode="full"/>
|
||||
</div>
|
||||
|
|
|
@ -12,6 +12,10 @@ export const Cache = {
|
|||
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) {
|
||||
this.storage.set(this.normalizeKey(key), {
|
||||
value,
|
||||
|
@ -45,6 +49,6 @@ export const Cache = {
|
|||
key = this.normalizeKey(key)
|
||||
|
||||
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 {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
|
||||
songStore.byAlbum(album).forEach(song => song.album_cover = album.cover)
|
||||
|
||||
// sync to vault
|
||||
this.byId(album.id).cover = album.cover
|
||||
|
||||
return album.cover
|
||||
},
|
||||
|
||||
|
|
|
@ -34,9 +34,12 @@ export const artistStore = {
|
|||
return !this.isVarious(artist) && !this.isUnknown(artist)
|
||||
},
|
||||
|
||||
uploadImage: async (artist: Artist, image: string) => {
|
||||
const { imageUrl } = await httpService.put<{ imageUrl: string }>(`artist/${artist.id}/image`, { image })
|
||||
artist.image = imageUrl
|
||||
async uploadImage (artist: Artist, image: string) {
|
||||
artist.image = (await httpService.put<{ imageUrl: string }>(`artist/${artist.id}/image`, { image })).imageUrl
|
||||
|
||||
// sync to vault
|
||||
this.byId(artist.id).image = 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 {
|
||||
readonly title: string
|
||||
readonly length: number
|
||||
fmt_length: string
|
||||
}
|
||||
|
||||
interface AlbumInfo {
|
||||
|
@ -145,7 +144,6 @@ interface Artist {
|
|||
readonly id: number
|
||||
name: string
|
||||
image: string | null
|
||||
info: ArtistInfo | null
|
||||
play_count: number
|
||||
album_count: number
|
||||
song_count: number
|
||||
|
@ -162,7 +160,6 @@ interface Album {
|
|||
name: string
|
||||
cover: string
|
||||
thumbnail?: string | null
|
||||
info: AlbumInfo | null
|
||||
play_count: number
|
||||
song_count: number
|
||||
length: number
|
||||
|
|
|
@ -151,6 +151,16 @@
|
|||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.close-modal {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -163,6 +173,7 @@
|
|||
|
||||
&.name {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
span {
|
||||
|
@ -180,7 +191,7 @@
|
|||
}
|
||||
|
||||
.bio {
|
||||
margin-top: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.more {
|
||||
|
@ -203,56 +214,7 @@
|
|||
}
|
||||
|
||||
.wiki {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
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\ExcerptSearchController;
|
||||
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\PlayCountController;
|
||||
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.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.songs', PlaylistSongController::class);
|
||||
Route::post('playlists/{playlist}/songs', [PlaylistSongController::class, 'add']);
|
||||
Route::delete('playlists/{playlist}/songs', [PlaylistSongController::class, 'remove']);
|
||||
|
||||
Route::apiResource('songs', SongController::class);
|
||||
|
||||
Route::get('users', [UserController::class, 'index']);
|
||||
|
||||
Route::get('search', ExcerptSearchController::class);
|
||||
|
|
Loading…
Reference in a new issue