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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 &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>
</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)

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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