feat: decouple artist/album and the media information

This commit is contained in:
Phan An 2022-07-08 16:53:04 +02:00
parent 7123d67c1a
commit 08e4953217
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
33 changed files with 326 additions and 321 deletions

View file

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

View file

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

View file

@ -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() ?: []);
} }
} }

View file

@ -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() ?: []);
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
];
}
}

View 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,
];
}
}

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

View file

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

View file

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

View file

@ -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 &copy; Data &copy;
<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>

View file

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

View file

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

View file

@ -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 &copy; Data &copy;
<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)

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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