mirror of
https://github.com/koel/koel
synced 2024-11-24 13:13:05 +00:00
feat: greatly reduce artist/album query complexity
This commit is contained in:
parent
3ec65c4197
commit
ad1d36085a
34 changed files with 162 additions and 326 deletions
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ class QueueController extends Controller
|
|||
$request->sort,
|
||||
$request->order,
|
||||
$request->limit,
|
||||
$this->user,
|
||||
$this->user
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -18,14 +18,6 @@
|
|||
<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"
|
||||
|
@ -34,8 +26,9 @@
|
|||
role="button"
|
||||
@click.prevent="shuffle"
|
||||
>
|
||||
<icon :icon="faRandom"/>
|
||||
Shuffle
|
||||
</a>
|
||||
•
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
:title="`Download all songs in the album ${album.name}`"
|
||||
|
@ -45,18 +38,16 @@
|
|||
role="button"
|
||||
@click.prevent="download"
|
||||
>
|
||||
<icon :icon="faDownload"/>
|
||||
Download
|
||||
</a>
|
||||
</span>
|
||||
</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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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 () => {
|
||||
|
|
|
@ -18,14 +18,6 @@
|
|||
<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"
|
||||
|
@ -34,8 +26,9 @@
|
|||
role="button"
|
||||
@click.prevent="shuffle"
|
||||
>
|
||||
<icon :icon="faRandom"/>
|
||||
Shuffle
|
||||
</a>
|
||||
•
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
:title="`Download all songs by ${artist.name}`"
|
||||
|
@ -45,18 +38,16 @@
|
|||
role="button"
|
||||
@click.prevent="download"
|
||||
>
|
||||
<icon :icon="faDownload"/>
|
||||
Download
|
||||
</a>
|
||||
</span>
|
||||
</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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
7
resources/assets/js/types.d.ts
vendored
7
resources/assets/js/types.d.ts
vendored
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -78,26 +78,15 @@
|
|||
color: var(--color-text-secondary);
|
||||
font-size: .9rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.right {
|
||||
display: none;
|
||||
|
||||
@media (hover: none) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
gap: .3rem;
|
||||
opacity: .7;
|
||||
|
||||
a {
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-text-primary);
|
||||
color: var(--color-bg-primary);
|
||||
}
|
||||
}
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,9 +14,6 @@ class AlbumTest extends TestCase
|
|||
'artist_name',
|
||||
'cover',
|
||||
'created_at',
|
||||
'length',
|
||||
'play_count',
|
||||
'song_count',
|
||||
];
|
||||
|
||||
private const JSON_COLLECTION_STRUCTURE = [
|
||||
|
|
|
@ -11,10 +11,6 @@ class ArtistTest extends TestCase
|
|||
'id',
|
||||
'name',
|
||||
'image',
|
||||
'length',
|
||||
'play_count',
|
||||
'song_count',
|
||||
'album_count',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
|
|
Loading…
Reference in a new issue