feat: greatly reduce artist/album query complexity

This commit is contained in:
Phan An 2022-10-11 17:28:43 +02:00
parent 3ec65c4197
commit ad1d36085a
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
34 changed files with 162 additions and 326 deletions

View file

@ -3,10 +3,7 @@
namespace App\Builders;
use App\Models\Album;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Facades\DB;
class AlbumBuilder extends Builder
{
@ -14,19 +11,4 @@ class AlbumBuilder extends Builder
{
return $this->whereNot('albums.id', Album::UNKNOWN_ID);
}
public function withMeta(User $user): static
{
$integer = $this->integerCastType();
return $this->with('artist')
->leftJoin('songs', 'albums.id', '=', 'songs.album_id')
->leftJoin('interactions', static function (JoinClause $join) use ($user): void {
$join->on('songs.id', '=', 'interactions.song_id')->where('interactions.user_id', $user->id);
})
->groupBy('albums.id')
->select('albums.*', DB::raw("CAST(SUM(interactions.play_count) AS $integer) AS play_count"))
->withCount('songs AS song_count')
->withSum('songs AS length', 'length');
}
}

View file

@ -3,10 +3,7 @@
namespace App\Builders;
use App\Models\Artist;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Facades\DB;
class ArtistBuilder extends Builder
{
@ -14,22 +11,4 @@ class ArtistBuilder extends Builder
{
return $this->whereNotIn('artists.id', [Artist::UNKNOWN_ID, Artist::VARIOUS_ID]);
}
public function withMeta(User $user): static
{
$integer = $this->integerCastType();
return $this->leftJoin('songs', 'artists.id', '=', 'songs.artist_id')
->leftJoin('interactions', static function (JoinClause $join) use ($user): void {
$join->on('interactions.song_id', '=', 'songs.id')->where('interactions.user_id', $user->id);
})
->groupBy('artists.id')
->select([
'artists.*',
DB::raw("CAST(SUM(interactions.play_count) AS $integer) AS play_count"),
DB::raw('COUNT(DISTINCT songs.album_id) AS album_count'),
])
->withCount('songs AS song_count')
->withSum('songs AS length', 'length');
}
}

View file

@ -6,7 +6,7 @@ use App\Models\Song;
use Illuminate\Support\Facades\Facade;
/**
* @method static fromSong(Song $song)
* @method static string fromSong(Song $song)
*/
class Download extends Facade
{

View file

@ -7,30 +7,26 @@ use App\Http\Requests\API\SongUpdateRequest;
use App\Http\Resources\AlbumResource;
use App\Http\Resources\ArtistResource;
use App\Http\Resources\SongResource;
use App\Models\User;
use App\Repositories\AlbumRepository;
use App\Repositories\ArtistRepository;
use App\Services\LibraryManager;
use App\Services\SongService;
use App\Values\SongUpdateData;
use Illuminate\Contracts\Auth\Authenticatable;
class SongController extends Controller
{
/** @param User $user */
public function __construct(
private SongService $songService,
private AlbumRepository $albumRepository,
private ArtistRepository $artistRepository,
private LibraryManager $libraryManager,
private ?Authenticatable $user
private LibraryManager $libraryManager
) {
}
public function update(SongUpdateRequest $request)
{
$updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request));
$albums = $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->toArray(), $this->user);
$albums = $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->toArray());
$artists = $this->artistRepository->getByIds(
array_merge(

View file

@ -5,24 +5,21 @@ namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\AlbumResource;
use App\Models\Album;
use App\Models\User;
use App\Repositories\AlbumRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class AlbumController extends Controller
{
/** @param User $user */
public function __construct(private AlbumRepository $repository, private ?Authenticatable $user)
public function __construct(private AlbumRepository $repository)
{
}
public function index()
{
return AlbumResource::collection($this->repository->paginate($this->user));
return AlbumResource::collection($this->repository->paginate());
}
public function show(Album $album)
{
return AlbumResource::make($this->repository->getOne($album->id, $this->user));
return AlbumResource::make($this->repository->getOne($album->id));
}
}

View file

@ -5,24 +5,21 @@ namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtistResource;
use App\Models\Artist;
use App\Models\User;
use App\Repositories\ArtistRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class ArtistController extends Controller
{
/** @param User $user */
public function __construct(private ArtistRepository $repository, private ?Authenticatable $user)
public function __construct(private ArtistRepository $repository)
{
}
public function index()
{
return ArtistResource::collection($this->repository->paginate($this->user));
return ArtistResource::collection($this->repository->paginate());
}
public function show(Artist $artist)
{
return ArtistResource::make($this->repository->getOne($artist->id, $this->user));
return ArtistResource::make($this->repository->getOne($artist->id));
}
}

View file

@ -26,7 +26,7 @@ class QueueController extends Controller
$request->sort,
$request->order,
$request->limit,
$this->user,
$this->user
)
);
}

View file

@ -23,9 +23,6 @@ class AlbumResource extends JsonResource
'artist_name' => $this->album->artist->name,
'cover' => $this->album->cover,
'created_at' => $this->album->created_at,
'length' => (float) $this->album->length,
'play_count' => (int) $this->album->play_count,
'song_count' => (int) $this->album->song_count,
];
}
}

View file

@ -20,10 +20,6 @@ class ArtistResource extends JsonResource
'id' => $this->artist->id,
'name' => $this->artist->name,
'image' => $this->artist->image,
'length' => (float) $this->artist->length,
'play_count' => (int) $this->artist->play_count,
'song_count' => (int) $this->artist->song_count,
'album_count' => (int) $this->artist->album_count,
'created_at' => $this->artist->created_at,
];
}

View file

@ -6,57 +6,57 @@ use App\Models\Album;
use App\Models\User;
use App\Repositories\Traits\Searchable;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
class AlbumRepository extends Repository
{
use Searchable;
public function getOne(int $id, ?User $scopedUser = null): Album
public function getOne(int $id): Album
{
return Album::query()
->withMeta($scopedUser ?? $this->auth->user())
->where('albums.id', $id)
->first();
return Album::query()->find($id);
}
/** @return Collection|array<array-key, Album> */
public function getRecentlyAdded(int $count = 6, ?User $scopedUser = null): Collection
public function getRecentlyAdded(int $count = 6): Collection
{
return Album::query()
->withMeta($scopedUser ?? $this->auth->user())
->isStandard()
->latest('albums.created_at')
->latest('created_at')
->limit($count)
->get();
}
/** @return Collection|array<array-key, Album> */
public function getMostPlayed(int $count = 6, ?User $scopedUser = null): Collection
public function getMostPlayed(int $count = 6, ?User $user = null): Collection
{
$user ??= $this->auth->user();
return Album::query()
->withMeta($scopedUser ?? $this->auth->user())
->leftJoin('songs', 'albums.id', 'songs.album_id')
->leftJoin('interactions', static function (JoinClause $join) use ($user): void {
$join->on('songs.id', 'interactions.song_id')->where('interactions.user_id', $user->id);
})
->isStandard()
->orderByDesc('play_count')
->limit($count)
->get();
->get('albums.*');
}
/** @return Collection|array<array-key, Album> */
public function getByIds(array $ids, ?User $scopedUser = null): Collection
public function getByIds(array $ids): Collection
{
return Album::query()
->withMeta($scopedUser ?? $this->auth->user())
->whereIn('albums.id', $ids)
->whereIn('id', $ids)
->get();
}
public function paginate(?User $scopedUser = null): Paginator
public function paginate(): Paginator
{
return Album::query()
->withMeta($scopedUser ?? $this->auth->user())
->isStandard()
->orderBy('albums.name')
->orderBy('name')
->simplePaginate(21);
}
}

View file

@ -7,46 +7,48 @@ use App\Models\User;
use App\Repositories\Traits\Searchable;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Query\JoinClause;
class ArtistRepository extends Repository
{
use Searchable;
/** @return Collection|array<array-key, Artist> */
public function getMostPlayed(int $count = 6, ?User $scopedUser = null): Collection
public function getMostPlayed(int $count = 6, ?User $user = null): Collection
{
$user ??= auth()->user();
return Artist::query()
->withMeta($scopedUser ?? $this->auth->user())
->leftJoin('songs', 'artists.id', '=', 'songs.artist_id')
->leftJoin('interactions', static function (JoinClause $join) use ($user): void {
$join->on('interactions.song_id', '=', 'songs.id')->where('interactions.user_id', $user->id);
})
->groupBy('artists.id')
->isStandard()
->orderByDesc('play_count')
->limit($count)
->get();
->get('artists.*');
}
public function getOne(int $id, ?User $scopedUser = null): Artist
public function getOne(int $id): Artist
{
return Artist::query()
->withMeta($scopedUser ?? $this->auth->user())
->where('artists.id', $id)
->first();
return Artist::query()->find($id);
}
/** @return Collection|array<array-key, Artist> */
public function getByIds(array $ids, ?User $scopedUser = null): Collection
public function getByIds(array $ids): Collection
{
return Artist::query()
->withMeta($scopedUser ?? $this->auth->user())
->isStandard()
->whereIn('artists.id', $ids)
->whereIn('id', $ids)
->get();
}
public function paginate(?User $scopedUser = null): Paginator
public function paginate(): Paginator
{
return Artist::query()
->withMeta($scopedUser ?? $this->auth->user())
->isStandard()
->orderBy('artists.name')
->orderBy('name')
->simplePaginate(21);
}
}

View file

@ -8,13 +8,12 @@ use App\Models\Song;
use App\Models\User;
use App\Values\SmartPlaylistRule;
use App\Values\SmartPlaylistRuleGroup;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class SmartPlaylistService
{
public function __construct(private Guard $auth)
public function __construct()
{
}
@ -23,7 +22,7 @@ class SmartPlaylistService
{
throw_unless($playlist->is_smart, NonSmartPlaylistException::create($playlist));
$query = Song::query()->withMeta($user ?? $this->auth->user());
$query = Song::query()->withMeta($user ?? $playlist->user);
$playlist->rule_groups->each(static function (SmartPlaylistRuleGroup $group, int $index) use ($query): void {
$clause = $index === 0 ? 'where' : 'orWhere';

View file

@ -37,14 +37,8 @@ class SearchService
Song::search($keywords)->get()->take($count)->pluck('id')->all(),
$scopedUser
),
$this->artistRepository->getByIds(
Artist::search($keywords)->get()->take($count)->pluck('id')->all(),
$scopedUser
),
$this->albumRepository->getByIds(
Album::search($keywords)->get()->take($count)->pluck('id')->all(),
$scopedUser
),
$this->artistRepository->getByIds(Artist::search($keywords)->get()->take($count)->pluck('id')->all()),
$this->albumRepository->getByIds(Album::search($keywords)->get()->take($count)->pluck('id')->all()),
);
}

View file

@ -1,18 +1,13 @@
import { Faker } from '@faker-js/faker'
export default (faker: Faker): Album => {
const length = faker.datatype.number({ min: 300 })
return {
type: 'albums',
artist_id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default
artist_name: faker.name.findName(),
song_count: faker.datatype.number(30),
id: faker.datatype.number({ min: 2 }), // avoid Unknown Album by default
name: faker.lorem.sentence(),
cover: faker.image.imageUrl(),
play_count: faker.datatype.number(),
length,
created_at: faker.date.past().toISOString()
}
}

View file

@ -1,17 +1,11 @@
import { Faker } from '@faker-js/faker'
export default (faker: Faker): Artist => {
const length = faker.datatype.number({ min: 300 })
return {
type: 'artists',
id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default
name: faker.name.findName(),
image: 'foo.jpg',
play_count: faker.datatype.number(),
album_count: faker.datatype.number({ max: 10 }),
song_count: faker.datatype.number({ max: 100 }),
length,
created_at: faker.date.past().toISOString()
}
}

View file

@ -11,10 +11,9 @@ let album: Album
new class extends UnitTestCase {
private renderComponent () {
album = factory<Album>('album', {
id: 42,
name: 'IV',
play_count: 30,
song_count: 10,
length: 123
artist_name: 'Led Zeppelin'
})
return this.render(AlbumCard, {
@ -26,12 +25,8 @@ new class extends UnitTestCase {
protected test () {
it('renders', () => {
const { getByText, getByTestId } = this.renderComponent()
expect(getByTestId('name').textContent).toBe('IV')
getByText(/^10 songs.+02:03.+30 plays$/)
getByTestId('shuffle-album')
getByTestId('download-album')
const { html } = this.renderComponent()
expect(html()).toMatchSnapshot()
})
it('downloads', async () => {

View file

@ -18,45 +18,36 @@
<a v-if="isStandardArtist" :href="`#/artist/${album.artist_id}`" class="artist">{{ album.artist_name }}</a>
<span v-else class="text-secondary">{{ album.artist_name }}</span>
<p class="meta">
<span class="left">
{{ pluralize(album.song_count, 'song') }}
{{ duration }}
{{ pluralize(album.play_count, 'play') }}
</span>
<span class="right">
<a
:title="`Shuffle all songs in the album ${album.name}`"
class="shuffle-album"
data-testid="shuffle-album"
href
role="button"
@click.prevent="shuffle"
>
<icon :icon="faRandom"/>
</a>
<a
v-if="allowDownload"
:title="`Download all songs in the album ${album.name}`"
class="download-album"
data-testid="download-album"
href
role="button"
@click.prevent="download"
>
<icon :icon="faDownload"/>
</a>
</span>
<a
:title="`Shuffle all songs in the album ${album.name}`"
class="shuffle-album"
data-testid="shuffle-album"
href
role="button"
@click.prevent="shuffle"
>
Shuffle
</a>
<a
v-if="allowDownload"
:title="`Download all songs in the album ${album.name}`"
class="download-album"
data-testid="download-album"
href
role="button"
@click.prevent="download"
>
Download
</a>
</p>
</footer>
</article>
</template>
<script lang="ts" setup>
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
import { computed, toRef, toRefs } from 'vue'
import { eventBus, pluralize, requireInjection, secondsToHis } from '@/utils'
import { eventBus, requireInjection, secondsToHis } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useDraggable } from '@/composables'

View file

@ -11,10 +11,7 @@ let album: Album
new class extends UnitTestCase {
private async renderComponent (_album?: Album) {
album = _album || factory<Album>('album', {
name: 'IV',
play_count: 30,
song_count: 10,
length: 123
name: 'IV'
})
const rendered = this.render(AlbumContextMenu)

View file

@ -0,0 +1,9 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<article class="full item" title="IV by Led Zeppelin" data-testid="album-card" draggable="true" tabindex="0" data-v-b204153b=""><span class="cover" data-testid="album-artist-thumbnail" data-v-e37470a2="" data-v-b204153b=""><a class="control control-play" href="" role="button" data-v-e37470a2=""><span class="hidden" data-v-e37470a2="">Play all songs in the album IV</span><span class="icon" data-v-e37470a2=""></span></a></span>
<footer data-v-b204153b=""><a href="#/album/42" class="name" data-testid="name" data-v-b204153b="">IV</a><a href="#/artist/76553" class="artist" data-v-b204153b="">Led Zeppelin</a>
<p class="meta" data-v-b204153b=""><a title="Shuffle all songs in the album IV" class="shuffle-album" data-testid="shuffle-album" href="" role="button" data-v-b204153b=""> Shuffle </a> • <a title="Download all songs in the album IV" class="download-album" data-testid="download-album" href="" role="button" data-v-b204153b=""> Download </a></p>
</footer>
</article>
`;

View file

@ -12,26 +12,21 @@ new class extends UnitTestCase {
protected beforeEach () {
super.beforeEach(() => {
artist = factory<Artist>('artist', {
name: 'Led Zeppelin',
album_count: 4,
play_count: 124,
song_count: 16
id: 42,
name: 'Led Zeppelin'
})
})
}
protected test () {
it('renders', () => {
const { getByText, getByTestId } = this.render(ArtistCard, {
const { html } = this.render(ArtistCard, {
props: {
artist
}
})
expect(getByTestId('name').textContent).toBe('Led Zeppelin')
getByText(/^4 albums\s+•\s+16 songs.+124 plays$/)
getByTestId('shuffle-artist')
getByTestId('download-artist')
expect(html()).toMatchSnapshot()
})
it('downloads', async () => {

View file

@ -18,45 +18,36 @@
<a :href="`#/artist/${artist.id}`" class="name" data-testid="name">{{ artist.name }}</a>
</div>
<p class="meta">
<span class="left">
{{ pluralize(artist.album_count, 'album') }}
{{ pluralize(artist.song_count, 'song') }}
{{ pluralize(artist.play_count, 'play') }}
</span>
<span class="right">
<a
:title="`Shuffle all songs by ${artist.name}`"
class="shuffle-artist"
data-testid="shuffle-artist"
href
role="button"
@click.prevent="shuffle"
>
<icon :icon="faRandom"/>
</a>
<a
v-if="allowDownload"
:title="`Download all songs by ${artist.name}`"
class="download-artist"
data-testid="download-artist"
href
role="button"
@click.prevent="download"
>
<icon :icon="faDownload"/>
</a>
</span>
<a
:title="`Shuffle all songs by ${artist.name}`"
class="shuffle-artist"
data-testid="shuffle-artist"
href
role="button"
@click.prevent="shuffle"
>
Shuffle
</a>
<a
v-if="allowDownload"
:title="`Download all songs by ${artist.name}`"
class="download-artist"
data-testid="download-artist"
href
role="button"
@click.prevent="download"
>
Download
</a>
</p>
</footer>
</article>
</template>
<script lang="ts" setup>
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
import { computed, toRef, toRefs } from 'vue'
import { eventBus, pluralize, requireInjection } from '@/utils'
import { eventBus, requireInjection } from '@/utils'
import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useDraggable } from '@/composables'

View file

@ -11,10 +11,7 @@ let artist: Artist
new class extends UnitTestCase {
private async renderComponent (_artist?: Artist) {
artist = _artist || factory<Artist>('artist', {
name: 'Accept',
play_count: 30,
song_count: 10,
length: 123
name: 'Accept'
})
const rendered = this.render(ArtistContextMenu)

View file

@ -0,0 +1,10 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<article class="full item" title="Led Zeppelin" data-testid="artist-card" draggable="true" tabindex="0" data-v-85d5de45=""><span class="cover" data-testid="album-artist-thumbnail" data-v-e37470a2="" data-v-85d5de45=""><a class="control control-play" href="" role="button" data-v-e37470a2=""><span class="hidden" data-v-e37470a2="">Play all songs by Led Zeppelin</span><span class="icon" data-v-e37470a2=""></span></a></span>
<footer data-v-85d5de45="">
<div class="info" data-v-85d5de45=""><a href="#/artist/42" class="name" data-testid="name" data-v-85d5de45="">Led Zeppelin</a></div>
<p class="meta" data-v-85d5de45=""><a title="Shuffle all songs by Led Zeppelin" class="shuffle-artist" data-testid="shuffle-artist" href="" role="button" data-v-85d5de45=""> Shuffle </a> • <a title="Download all songs by Led Zeppelin" class="download-artist" data-testid="download-artist" href="" role="button" data-v-85d5de45=""> Download </a></p>
</footer>
</article>
`;

View file

@ -11,7 +11,7 @@
</template>
<template v-slot:meta>
<span>{{ pluralize(artist.album_count, 'album') }}</span>
<span>{{ pluralize(albumCount, 'album') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a v-if="useLastfm" class="info" href title="View artist information" @click.prevent="showInfo">Info</a>
@ -50,7 +50,7 @@
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { eventBus, logger, pluralize, requireInjection } from '@/utils'
import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
@ -92,6 +92,12 @@ const {
const { useLastfm } = useThirdPartyServices()
const allowDownload = toRef(commonStore.state, 'allow_download')
const albumCount = computed(() => {
const albums = new Set()
songs.value.forEach(song => albums.add(song.album_id))
return albums.size
})
const download = () => downloadService.fromArtist(artist.value!)
const showInfo = () => (showingInfo.value = true)

View file

@ -1,5 +1,5 @@
import { reactive, UnwrapNestedRefs } from 'vue'
import { differenceBy, merge, orderBy, take, unionBy } from 'lodash'
import { differenceBy, merge, unionBy } from 'lodash'
import { cache, http } from '@/services'
import { arrayify, logger } from '@/utils'
import { songStore } from '@/stores'
@ -84,19 +84,5 @@ export const albumStore = {
this.state.albums = unionBy(this.state.albums, this.syncWithVault(resource.data), 'id')
return resource.links.next ? ++resource.meta.current_page : null
},
getMostPlayed (count: number) {
return take(
orderBy(Array.from(this.vault.values()).filter(album => !this.isUnknown(album)), 'play_count', 'desc'),
count
)
},
getRecentlyAdded (count: number) {
return take(
orderBy(Array.from(this.vault.values()).filter(album => !this.isUnknown(album)), 'created_at', 'desc'),
count
)
}
}

View file

@ -1,5 +1,5 @@
import { reactive, UnwrapNestedRefs } from 'vue'
import { differenceBy, orderBy, take, unionBy } from 'lodash'
import { differenceBy, unionBy } from 'lodash'
import { cache, http } from '@/services'
import { arrayify, logger } from '@/utils'
@ -74,12 +74,5 @@ export const artistStore = {
this.state.artists = unionBy(this.state.artists, this.syncWithVault(resource.data), 'id')
return resource.links.next ? ++resource.meta.current_page : null
},
getMostPlayed (count: number) {
return take(
orderBy(Array.from(this.vault.values()).filter(artist => this.isStandard(artist)), 'play_count', 'desc'),
count
)
}
}

View file

@ -44,42 +44,28 @@ new class extends UnitTestCase {
await overviewStore.init()
expect(getMock).toHaveBeenCalledWith('overview')
expect(songSyncMock).toHaveBeenNthCalledWith(1, [...mostPlayedSongs, ...recentlyAddedSongs])
expect(songSyncMock).toHaveBeenNthCalledWith(2, recentlyPlayedSongs)
expect(albumSyncMock).toHaveBeenCalledWith([...mostPlayedAlbums, ...recentlyAddedAlbums])
expect(songSyncMock).toHaveBeenNthCalledWith(1, mostPlayedSongs)
expect(songSyncMock).toHaveBeenNthCalledWith(2, recentlyAddedSongs)
expect(songSyncMock).toHaveBeenNthCalledWith(3, recentlyPlayedSongs)
expect(albumSyncMock).toHaveBeenNthCalledWith(1, recentlyAddedAlbums)
expect(albumSyncMock).toHaveBeenNthCalledWith(2, mostPlayedAlbums)
expect(artistSyncMock).toHaveBeenCalledWith(mostPlayedArtists)
expect(refreshMock).toHaveBeenCalled()
})
it('refreshes the store', () => {
const mostPlayedSongs = factory<Song>('song', 7)
const mostPlayedAlbums = factory<Album>('album', 6)
const mostPlayedArtists = factory<Artist>('artist', 6)
const recentlyAddedSongs = factory<Song>('song', 9)
const recentlyAddedAlbums = factory<Album>('album', 6)
const recentlyPlayedSongs = factory<Song>('song', 9)
const mostPlayedSongsMock = this.mock(songStore, 'getMostPlayed', mostPlayedSongs)
const mostPlayedAlbumsMock = this.mock(albumStore, 'getMostPlayed', mostPlayedAlbums)
const mostPlayedArtistsMock = this.mock(artistStore, 'getMostPlayed', mostPlayedArtists)
const recentlyAddedSongsMock = this.mock(songStore, 'getRecentlyAdded', recentlyAddedSongs)
const recentlyAddedAlbumsMock = this.mock(albumStore, 'getRecentlyAdded', recentlyAddedAlbums)
recentlyPlayedStore.excerptState.songs = recentlyPlayedSongs
overviewStore.refresh()
expect(mostPlayedSongsMock).toHaveBeenCalled()
expect(mostPlayedAlbumsMock).toHaveBeenCalled()
expect(mostPlayedArtistsMock).toHaveBeenCalled()
expect(recentlyAddedSongsMock).toHaveBeenCalled()
expect(recentlyAddedAlbumsMock).toHaveBeenCalled()
expect(overviewStore.state.recentlyPlayed).toEqual(recentlyPlayedSongs)
expect(overviewStore.state.recentlyAddedSongs).toEqual(recentlyAddedSongs)
expect(overviewStore.state.recentlyAddedAlbums).toEqual(recentlyAddedAlbums)
expect(overviewStore.state.mostPlayedSongs).toEqual(mostPlayedSongs)
expect(overviewStore.state.mostPlayedAlbums).toEqual(mostPlayedAlbums)
expect(overviewStore.state.mostPlayedArtists).toEqual(mostPlayedArtists)
})
}
}

View file

@ -25,21 +25,25 @@ export const overviewStore = {
recently_played_songs: Song[],
}>('overview')
songStore.syncWithVault([...resource.most_played_songs, ...resource.recently_added_songs])
albumStore.syncWithVault([...resource.most_played_albums, ...resource.recently_added_albums])
songStore.syncWithVault(resource.most_played_songs)
albumStore.syncWithVault(resource.recently_added_albums)
artistStore.syncWithVault(resource.most_played_artists)
this.state.mostPlayedAlbums = albumStore.syncWithVault(resource.most_played_albums)
this.state.mostPlayedArtists = artistStore.syncWithVault(resource.most_played_artists)
this.state.recentlyAddedSongs = songStore.syncWithVault(resource.recently_added_songs)
this.state.recentlyAddedAlbums = albumStore.syncWithVault(resource.recently_added_albums)
recentlyPlayedStore.excerptState.songs = songStore.syncWithVault(resource.recently_played_songs)
this.refresh()
},
refresh () {
// @since v6.2.3
// To keep things simple, we only refresh the song stats.
// All album/artist stats are simply ignored.
this.state.mostPlayedSongs = songStore.getMostPlayed(7)
this.state.mostPlayedAlbums = albumStore.getMostPlayed(6)
this.state.mostPlayedArtists = artistStore.getMostPlayed(6)
this.state.recentlyAddedSongs = songStore.getRecentlyAdded(9)
this.state.recentlyAddedAlbums = albumStore.getRecentlyAdded(6)
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.songs.filter(song => !song.deleted)
}
}

View file

@ -166,27 +166,20 @@ new class extends UnitTestCase {
playback_state: null
})
const trackPlayCountMock = this.mock(songStore, 'setUpPlayCountTracking')
const watchPlayCountMock = this.mock(songStore, 'watchPlayCount')
expect(songStore.syncWithVault(song)).toEqual([reactive(song)])
expect(songStore.vault.has(song.id)).toBe(true)
expect(trackPlayCountMock).toHaveBeenCalledOnce()
expect(watchPlayCountMock).toHaveBeenCalledOnce()
expect(songStore.syncWithVault(song)).toEqual([reactive(song)])
expect(songStore.vault.has(song.id)).toBe(true)
// second call shouldn't set up play count tracking again
expect(trackPlayCountMock).toHaveBeenCalledOnce()
expect(watchPlayCountMock).toHaveBeenCalledOnce()
})
it('sets up play count tracking', async () => {
it('watches play count tracking', async () => {
const refreshMock = this.mock(overviewStore, 'refresh')
const artist = reactive(factory<Artist>('artist', { id: 42, play_count: 100 }))
const album = reactive(factory<Album>('album', { id: 10, play_count: 120 }))
const albumArtist = reactive(factory<Artist>('artist', { id: 43, play_count: 130 }))
artistStore.vault.set(42, artist)
artistStore.vault.set(43, albumArtist)
albumStore.vault.set(10, album)
const song = reactive(factory<Song>('song', {
album_id: 10,
@ -195,14 +188,11 @@ new class extends UnitTestCase {
play_count: 98
}))
songStore.setUpPlayCountTracking(song)
songStore.watchPlayCount(song)
song.play_count = 100
await this.tick()
expect(artist.play_count).toBe(102)
expect(album.play_count).toBe(122)
expect(albumArtist.play_count).toBe(132)
expect(refreshMock).toHaveBeenCalled()
})

View file

@ -128,7 +128,7 @@ export const songStore = {
} else {
local = reactive(song)
local.playback_state = 'Stopped'
this.setUpPlayCountTracking(local)
this.watchPlayCount(local)
this.vault.set(local.id, local)
}
@ -136,21 +136,8 @@ export const songStore = {
})
},
setUpPlayCountTracking: (song: UnwrapNestedRefs<Song>) => {
watch(() => song.play_count, (newCount, oldCount) => {
const album = albumStore.byId(song.album_id)
album && (album.play_count += (newCount - oldCount))
const artist = artistStore.byId(song.artist_id)
artist && (artist.play_count += (newCount - oldCount))
if (song.album_artist_id !== song.artist_id) {
const albumArtist = artistStore.byId(song.album_artist_id)
albumArtist && (albumArtist.play_count += (newCount - oldCount))
}
overviewStore.refresh()
})
watchPlayCount: (song: UnwrapNestedRefs<Song>) => {
watch(() => song.play_count, () => overviewStore.refresh())
},
async cacheable (key: any, fetcher: Promise<Song[]>) {
@ -197,10 +184,6 @@ export const songStore = {
return take(orderBy(Array.from(this.vault.values()).filter(song => !song.deleted), 'play_count', 'desc'), count)
},
getRecentlyAdded (count: number) {
return take(orderBy(Array.from(this.vault.values()).filter(song => !song.deleted), 'created_at', 'desc'), count)
},
async deleteFromFilesystem (songs: Song[]) {
const ids = songs.map(song => {
// Whenever a vault sync is requested (e.g. upon playlist/album/artist fetching)

View file

@ -113,10 +113,6 @@ interface Artist {
readonly id: number
name: string
image: string | null
play_count: number
album_count: number
song_count: number
length: number
created_at: string
}
@ -128,9 +124,6 @@ interface Album {
name: string
cover: string
thumbnail?: string | null
play_count: number
song_count: number
length: number
created_at: string
}

View file

@ -78,26 +78,15 @@
color: var(--color-text-secondary);
font-size: .9rem;
display: flex;
justify-content: space-between;
gap: .3rem;
opacity: .7;
.right {
display: none;
a {
border-radius: 3px;
}
@media (hover: none) {
display: flex;
}
gap: .3rem;
a {
padding: 0 4px;
border-radius: 3px;
&:hover {
background: var(--color-text-primary);
color: var(--color-bg-primary);
}
}
&:hover {
opacity: 1;
}
}

View file

@ -14,9 +14,6 @@ class AlbumTest extends TestCase
'artist_name',
'cover',
'created_at',
'length',
'play_count',
'song_count',
];
private const JSON_COLLECTION_STRUCTURE = [

View file

@ -11,10 +11,6 @@ class ArtistTest extends TestCase
'id',
'name',
'image',
'length',
'play_count',
'song_count',
'album_count',
'created_at',
];