mirror of
https://github.com/koel/koel
synced 2024-09-20 06:11:53 +00:00
feat: add Genres screens (#1541)
This commit is contained in:
parent
1f3dccbe5b
commit
c70bb3b5af
43 changed files with 857 additions and 19 deletions
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V6\API\FetchRandomSongsInGenreRequest;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class FetchRandomSongsInGenreController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __invoke(
|
||||
FetchRandomSongsInGenreRequest $request,
|
||||
SongRepository $repository,
|
||||
Authenticatable $user
|
||||
) {
|
||||
return SongResource::collection($repository->getRandomByGenre($request->genre, $request->limit, $user));
|
||||
}
|
||||
}
|
31
app/Http/Controllers/V6/API/GenreController.php
Normal file
31
app/Http/Controllers/V6/API/GenreController.php
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\GenreResource;
|
||||
use App\Repositories\GenreRepository;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class GenreController extends Controller
|
||||
{
|
||||
public function __construct(private GenreRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return GenreResource::collection($this->repository->getAll());
|
||||
}
|
||||
|
||||
public function show(string $name)
|
||||
{
|
||||
$genre = $this->repository->getOne($name);
|
||||
|
||||
if (!$genre) {
|
||||
abort(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return GenreResource::make($genre);
|
||||
}
|
||||
}
|
37
app/Http/Controllers/V6/API/GenreSongController.php
Normal file
37
app/Http/Controllers/V6/API/GenreSongController.php
Normal file
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V6\API\GenreFetchSongRequest;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Values\Genre;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class GenreSongController extends Controller
|
||||
{
|
||||
/**
|
||||
* @param User $user
|
||||
*/
|
||||
public function __invoke(
|
||||
string $genre,
|
||||
SongRepository $repository,
|
||||
Authenticatable $user,
|
||||
GenreFetchSongRequest $request
|
||||
) {
|
||||
if ($genre === Genre::NO_GENRE) {
|
||||
$genre = '';
|
||||
}
|
||||
|
||||
return SongResource::collection(
|
||||
$repository->getByGenre(
|
||||
$genre,
|
||||
$request->sort ?: 'songs.title',
|
||||
$request->order ?: 'asc',
|
||||
$user
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
13
app/Http/Requests/V6/API/FetchRandomSongsInGenreRequest.php
Normal file
13
app/Http/Requests/V6/API/FetchRandomSongsInGenreRequest.php
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\V6\API;
|
||||
|
||||
use App\Http\Requests\API\Request;
|
||||
|
||||
/**
|
||||
* @property-read string $genre
|
||||
* @property-read int $limit
|
||||
*/
|
||||
class FetchRandomSongsInGenreRequest extends Request
|
||||
{
|
||||
}
|
13
app/Http/Requests/V6/API/GenreFetchSongRequest.php
Normal file
13
app/Http/Requests/V6/API/GenreFetchSongRequest.php
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\V6\API;
|
||||
|
||||
use App\Http\Requests\API\Request;
|
||||
|
||||
/**
|
||||
* @property-read string $order
|
||||
* @property-read string $sort
|
||||
*/
|
||||
class GenreFetchSongRequest extends Request
|
||||
{
|
||||
}
|
25
app/Http/Resources/GenreResource.php
Normal file
25
app/Http/Resources/GenreResource.php
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Values\Genre;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class GenreResource extends JsonResource
|
||||
{
|
||||
public function __construct(private Genre $genre)
|
||||
{
|
||||
parent::__construct($genre);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'genres',
|
||||
'name' => $this->genre->name,
|
||||
'song_count' => $this->genre->songCount,
|
||||
'length' => $this->genre->length,
|
||||
];
|
||||
}
|
||||
}
|
44
app/Repositories/GenreRepository.php
Normal file
44
app/Repositories/GenreRepository.php
Normal file
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Values\Genre;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GenreRepository
|
||||
{
|
||||
/** @return Collection|array<array-key, Genre> */
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Song::query()
|
||||
->select('genre', DB::raw('COUNT(id) AS song_count'), DB::raw('SUM(length) AS length'))
|
||||
->groupBy('genre')
|
||||
->orderBy('genre')
|
||||
->get()
|
||||
->transform(static fn (object $record): Genre => Genre::make(
|
||||
name: $record->genre ?: Genre::NO_GENRE,
|
||||
songCount: $record->song_count,
|
||||
length: $record->length
|
||||
));
|
||||
}
|
||||
|
||||
public function getOne(string $name): ?Genre
|
||||
{
|
||||
/** @var object|null $record */
|
||||
$record = Song::query()
|
||||
->select('genre', DB::raw('COUNT(id) AS song_count'), DB::raw('SUM(length) AS length'))
|
||||
->groupBy('genre')
|
||||
->where('genre', $name === Genre::NO_GENRE ? '' : $name)
|
||||
->first();
|
||||
|
||||
return $record
|
||||
? Genre::make(
|
||||
name: $record->genre ?: Genre::NO_GENRE,
|
||||
songCount: $record->song_count,
|
||||
length: $record->length
|
||||
)
|
||||
: null;
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ use App\Models\Playlist;
|
|||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Traits\Searchable;
|
||||
use App\Values\Genre;
|
||||
use Illuminate\Contracts\Database\Query\Builder;
|
||||
use Illuminate\Contracts\Pagination\Paginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
@ -82,6 +83,23 @@ class SongRepository extends Repository
|
|||
->simplePaginate($perPage);
|
||||
}
|
||||
|
||||
public function getByGenre(
|
||||
string $genre,
|
||||
string $sortColumn,
|
||||
string $sortDirection,
|
||||
?User $scopedUser = null,
|
||||
int $perPage = 50
|
||||
): Paginator {
|
||||
return self::applySort(
|
||||
Song::query()
|
||||
->withMeta($scopedUser ?? $this->auth->user())
|
||||
->where('genre', $genre),
|
||||
$sortColumn,
|
||||
$sortDirection
|
||||
)
|
||||
->simplePaginate($perPage);
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getForQueue(
|
||||
string $sortColumn,
|
||||
|
@ -202,4 +220,15 @@ class SongRepository extends Repository
|
|||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getRandomByGenre(string $genre, int $limit, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::query()
|
||||
->withMeta($scopedUser ?? $this->auth->user())
|
||||
->where('genre', $genre === Genre::NO_GENRE ? '' : $genre)
|
||||
->limit($limit)
|
||||
->inRandomOrder()
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
|
17
app/Values/Genre.php
Normal file
17
app/Values/Genre.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Values;
|
||||
|
||||
final class Genre
|
||||
{
|
||||
public const NO_GENRE = 'No Genre';
|
||||
|
||||
private function __construct(public string $name, public int $songCount, public float $length)
|
||||
{
|
||||
}
|
||||
|
||||
public static function make(string $name, int $songCount, float $length): self
|
||||
{
|
||||
return new self(name: $name, songCount: $songCount, length: $length);
|
||||
}
|
||||
}
|
11
resources/assets/js/__tests__/factory/genreFactory.ts
Normal file
11
resources/assets/js/__tests__/factory/genreFactory.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import { genres } from '@/config'
|
||||
|
||||
export default (faker: Faker): Genre => {
|
||||
return {
|
||||
type: 'genres',
|
||||
name: faker.helpers.arrayElement(genres),
|
||||
song_count: faker.datatype.number({ min: 1, max: 1_000 }),
|
||||
length: faker.datatype.number({ min: 300, max: 300_000 })
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ import albumTrackFactory from '@/__tests__/factory/albumTrackFactory'
|
|||
import albumInfoFactory from '@/__tests__/factory/albumInfoFactory'
|
||||
import artistInfoFactory from '@/__tests__/factory/artistInfoFactory'
|
||||
import youTubeVideoFactory from '@/__tests__/factory/youTubeVideoFactory'
|
||||
import genreFactory from '@/__tests__/factory/genreFactory'
|
||||
|
||||
export default factory
|
||||
.define('artist', faker => artistFactory(faker), artistStates)
|
||||
|
@ -21,6 +22,7 @@ export default factory
|
|||
.define('album-info', faker => albumInfoFactory(faker))
|
||||
.define('song', faker => songFactory(faker), songStates)
|
||||
.define('interaction', faker => interactionFactory(faker))
|
||||
.define('genre', faker => genreFactory(faker))
|
||||
.define('video', faker => youTubeVideoFactory(faker))
|
||||
.define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker))
|
||||
.define('smart-playlist-rule-group', faker => smartPlaylistRuleGroupFactory(faker))
|
||||
|
|
|
@ -68,7 +68,7 @@ const isStandardArtist = computed(() => artistStore.isStandard(album.value.artis
|
|||
const showing = computed(() => !albumStore.isUnknown(album.value))
|
||||
|
||||
const shuffle = async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true /* shuffled */)
|
||||
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true /* shuffled */)
|
||||
router.go('queue')
|
||||
}
|
||||
|
||||
|
|
|
@ -35,12 +35,12 @@ const isStandardArtist = computed(() => {
|
|||
})
|
||||
|
||||
const play = () => trigger(async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!))
|
||||
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!))
|
||||
router.go('queue')
|
||||
})
|
||||
|
||||
const shuffle = () => trigger(async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!), true)
|
||||
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!), true)
|
||||
router.go('queue')
|
||||
})
|
||||
|
||||
|
|
|
@ -64,7 +64,7 @@ const showSummary = computed(() => mode.value !== 'full' && !showingFullWiki.val
|
|||
const showFull = computed(() => !showSummary.value)
|
||||
|
||||
const play = async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value))
|
||||
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value))
|
||||
router.go('queue')
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -66,7 +66,7 @@ const allowDownload = toRef(commonStore.state, 'allow_download')
|
|||
const showing = computed(() => artistStore.isStandard(artist.value))
|
||||
|
||||
const shuffle = async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true /* shuffled */)
|
||||
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true /* shuffled */)
|
||||
router.go('queue')
|
||||
}
|
||||
|
||||
|
|
|
@ -35,12 +35,12 @@ const isStandardArtist = computed(() =>
|
|||
)
|
||||
|
||||
const play = () => trigger(async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!))
|
||||
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!))
|
||||
router.go('queue')
|
||||
})
|
||||
|
||||
const shuffle = () => trigger(async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!), true)
|
||||
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!), true)
|
||||
router.go('queue')
|
||||
})
|
||||
|
||||
|
|
|
@ -60,7 +60,7 @@ const showSummary = computed(() => mode.value !== 'full' && !showingFullBio.valu
|
|||
const showFull = computed(() => !showSummary.value)
|
||||
|
||||
const play = async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value))
|
||||
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value))
|
||||
router.go('queue')
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -18,7 +18,9 @@
|
|||
<RecentlyPlayedScreen v-show="screen === 'RecentlyPlayed'"/>
|
||||
<UploadScreen v-show="screen === 'Upload'"/>
|
||||
<SearchExcerptsScreen v-show="screen === 'Search.Excerpt'"/>
|
||||
<GenreScreen v-show="screen === 'Genre'"/>
|
||||
|
||||
<GenreListScreen v-if="screen === 'Genres'"/>
|
||||
<SearchSongResultsScreen v-if="screen === 'Search.Songs'"/>
|
||||
<AlbumScreen v-if="screen === 'Album'"/>
|
||||
<ArtistScreen v-if="screen === 'Artist'"/>
|
||||
|
@ -41,6 +43,7 @@ import HomeScreen from '@/components/screens/HomeScreen.vue'
|
|||
import QueueScreen from '@/components/screens/QueueScreen.vue'
|
||||
import AlbumListScreen from '@/components/screens/AlbumListScreen.vue'
|
||||
import ArtistListScreen from '@/components/screens/ArtistListScreen.vue'
|
||||
import GenreListScreen from '@/components/screens/GenreListScreen.vue'
|
||||
import AllSongsScreen from '@/components/screens/AllSongsScreen.vue'
|
||||
import PlaylistScreen from '@/components/screens/PlaylistScreen.vue'
|
||||
import FavoritesScreen from '@/components/screens/FavoritesScreen.vue'
|
||||
|
@ -52,6 +55,7 @@ const UserListScreen = defineAsyncComponent(() => import('@/components/screens/U
|
|||
const AlbumArtOverlay = defineAsyncComponent(() => import('@/components/ui/AlbumArtOverlay.vue'))
|
||||
const AlbumScreen = defineAsyncComponent(() => import('@/components/screens/AlbumScreen.vue'))
|
||||
const ArtistScreen = defineAsyncComponent(() => import('@/components/screens/ArtistScreen.vue'))
|
||||
const GenreScreen = defineAsyncComponent(() => import('@/components/screens/GenreScreen.vue'))
|
||||
const SettingsScreen = defineAsyncComponent(() => import('@/components/screens/SettingsScreen.vue'))
|
||||
const ProfileScreen = defineAsyncComponent(() => import('@/components/screens/ProfileScreen.vue'))
|
||||
const YoutubeScreen = defineAsyncComponent(() => import('@/components/screens/YouTubeScreen.vue'))
|
||||
|
|
|
@ -40,6 +40,12 @@
|
|||
Artists
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a :class="['genres', activeScreen === 'Genres' ? 'active' : '']" href="#/genres">
|
||||
<icon :icon="faTags" fixed-width/>
|
||||
Genres
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="useYouTube">
|
||||
<a :class="['youtube', activeScreen === 'YouTube' ? 'active' : '']" href="#/youtube">
|
||||
<icon :icon="faYoutube" fixed-width/>
|
||||
|
@ -85,6 +91,7 @@ import {
|
|||
faListOl,
|
||||
faMicrophone,
|
||||
faMusic,
|
||||
faTags,
|
||||
faTools,
|
||||
faUpload,
|
||||
faUsers
|
||||
|
|
|
@ -30,12 +30,12 @@ const playlistsInFolder = computed(() => folder.value ? playlistStore.byFolder(f
|
|||
const playable = computed(() => playlistsInFolder.value.length > 0)
|
||||
|
||||
const play = () => trigger(async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!))
|
||||
playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!))
|
||||
router.go('queue')
|
||||
})
|
||||
|
||||
const shuffle = () => trigger(async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!), true)
|
||||
playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!), true)
|
||||
router.go('queue')
|
||||
})
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import AlbumScreen from './AlbumScreen.vue'
|
|||
let album: Album
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected async renderComponent () {
|
||||
private async renderComponent () {
|
||||
commonStore.state.use_last_fm = true
|
||||
|
||||
album = factory<Album>('album', {
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { genreStore } from '@/stores'
|
||||
import GenreListScreen from './GenreListScreen.vue'
|
||||
import { waitFor } from '@testing-library/vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it('renders the list of genres and their song counts', async () => {
|
||||
// ensure there's no duplicated names
|
||||
const genres = [
|
||||
factory<Genre>('genre', { name: 'Rock', song_count: 10 }),
|
||||
factory<Genre>('genre', { name: 'Pop', song_count: 20 }),
|
||||
factory<Genre>('genre', { name: 'Jazz', song_count: 30 })
|
||||
]
|
||||
|
||||
const fetchMock = this.mock(genreStore, 'fetchAll').mockResolvedValue(genres)
|
||||
|
||||
const { getByTitle } = this.render(GenreListScreen)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled()
|
||||
genres.forEach(genre => getByTitle(`${genre.name}: ${genre.song_count} songs`))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
109
resources/assets/js/components/screens/GenreListScreen.vue
Normal file
109
resources/assets/js/components/screens/GenreListScreen.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<section id="genresWrapper">
|
||||
<ScreenHeader layout="compact">
|
||||
Genres
|
||||
</ScreenHeader>
|
||||
<div class="main-scroll-wrap">
|
||||
<ul class="genres" v-if="genres">
|
||||
<li v-for="genre in genres" :key="genre.name" :class="`level-${getLevel(genre)}`">
|
||||
<a :href="`/#/genres/${genre.name}`" :title="`${genre.name}: ${pluralize(genre.song_count, 'song')}`">
|
||||
<span class="name">{{ genre.name }}</span>
|
||||
<span class="count">{{ genre.song_count }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="genres" v-else>
|
||||
<li v-for="i in 20" :key="i">
|
||||
<GenreItemSkeleton/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { maxBy, minBy } from 'lodash'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { genreStore } from '@/stores'
|
||||
import { pluralize } from '@/utils'
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import GenreItemSkeleton from '@/components/ui/skeletons/GenreItemSkeleton.vue'
|
||||
|
||||
const genres = ref<Genre[]>()
|
||||
|
||||
const mostPopular = computed(() => maxBy(genres.value, 'song_count'))
|
||||
const leastPopular = computed(() => minBy(genres.value, 'song_count'))
|
||||
|
||||
const levels = computed(() => {
|
||||
const max = mostPopular.value?.song_count || 1
|
||||
const min = leastPopular.value?.song_count || 1
|
||||
const range = max - min
|
||||
const step = range / 5
|
||||
|
||||
return [min, min + step, min + step * 2, min + step * 3, min + step * 4, max]
|
||||
})
|
||||
|
||||
const getLevel = (genre: Genre) => {
|
||||
const index = levels.value.findIndex((level) => genre.song_count <= level)
|
||||
return index === -1 ? 5 : index
|
||||
}
|
||||
|
||||
onMounted(async () => genres.value = await genreStore.fetchAll())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.genres {
|
||||
text-align: center;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
border-radius: 999rem;
|
||||
margin: .2rem;
|
||||
font-size: var(--unit);
|
||||
transition: opacity .2s ease-in-out, transform .2s ease-in-out;
|
||||
vertical-align: middle;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
background: rgba(255, 255, 255, .03);
|
||||
display: inline-flex;
|
||||
padding: calc(var(--unit) / 4) calc(var(--unit) / 4) calc(var(--unit) / 4) calc(var(--unit));
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-radius: 999rem;
|
||||
transition: background-color .2s ease-in-out, color .2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-highlight);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateX(2px) translateY(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
padding: calc(var(--unit) - 1rem) calc(var(--unit) / 2);
|
||||
background: rgba(255, 255, 255, .1);
|
||||
border-radius: 999rem;
|
||||
font-size: calc(var(--unit) * 2 / 3);
|
||||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, .3);
|
||||
min-width: calc(var(--unit) * 1.5);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 0 through 5 {
|
||||
.level-#{$i} {
|
||||
$zoom: 1 + $i * .4;
|
||||
--unit: #{$zoom}rem;
|
||||
opacity: .8 + $i * .04;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
77
resources/assets/js/components/screens/GenreScreen.spec.ts
Normal file
77
resources/assets/js/components/screens/GenreScreen.spec.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { genreStore, songStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
import GenreScreen from './GenreScreen.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private async renderComponent (genre?: Genre, songs?: Song[]) {
|
||||
genre = genre || factory<Genre>('genre')
|
||||
|
||||
const fetchGenreMock = this.mock(genreStore, 'fetchOne').mockResolvedValue(genre)
|
||||
const paginateMock = this.mock(songStore, 'paginateForGenre').mockResolvedValue({
|
||||
nextPage: 2,
|
||||
songs: songs || factory<Song>('song', 13)
|
||||
})
|
||||
|
||||
await this.router.activateRoute({
|
||||
path: `genres/${genre.name}`,
|
||||
screen: 'Genre'
|
||||
}, { name: genre.name })
|
||||
|
||||
const rendered = this.render(GenreScreen, {
|
||||
global: {
|
||||
stubs: {
|
||||
SongList: this.stub('song-list')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchGenreMock).toHaveBeenCalledWith(genre.name)
|
||||
expect(paginateMock).toHaveBeenCalledWith(genre.name, 'title', 'asc', 1)
|
||||
})
|
||||
|
||||
await this.tick(2)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
protected test () {
|
||||
it('renders the song list', async () => {
|
||||
const { getByTestId } = await this.renderComponent()
|
||||
|
||||
expect(getByTestId('song-list')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shuffles all songs without fetching if genre has <= 500 songs', async () => {
|
||||
const genre = factory<Genre>('genre', { song_count: 10 })
|
||||
const songs = factory<Song>('song', 10)
|
||||
const playbackMock = this.mock(playbackService, 'queueAndPlay')
|
||||
|
||||
const { getByTitle } = await this.renderComponent(genre, songs)
|
||||
|
||||
await fireEvent.click(getByTitle('Shuffle all songs'))
|
||||
|
||||
expect(playbackMock).toHaveBeenCalledWith(songs, true)
|
||||
})
|
||||
|
||||
it('fetches and shuffles all songs if genre has > 500 songs', async () => {
|
||||
const genre = factory<Genre>('genre', { song_count: 501 })
|
||||
const songs = factory<Song>('song', 10) // we don't really need to generate 501 songs
|
||||
const playbackMock = this.mock(playbackService, 'queueAndPlay')
|
||||
const fetchMock = this.mock(songStore, 'fetchRandomForGenre').mockResolvedValue(songs)
|
||||
|
||||
const { getByTitle } = await this.renderComponent(genre, songs)
|
||||
|
||||
await fireEvent.click(getByTitle('Shuffle all songs'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(genre, 500)
|
||||
expect(playbackMock).toHaveBeenCalledWith(songs)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
157
resources/assets/js/components/screens/GenreScreen.vue
Normal file
157
resources/assets/js/components/screens/GenreScreen.vue
Normal file
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<section id="genreWrapper">
|
||||
<ScreenHeader :layout="headerLayout" v-if="genre">
|
||||
Genre: <span class="text-thin">{{ name }}</span>
|
||||
<ControlsToggle v-if="songs.length" v-model="showingControls"/>
|
||||
|
||||
<template v-slot:thumbnail>
|
||||
<ThumbnailStack :thumbnails="thumbnails"/>
|
||||
</template>
|
||||
|
||||
<template v-if="genre" v-slot:meta>
|
||||
<span>{{ pluralize(genre.song_count, 'song') }}</span>
|
||||
<span>{{ duration }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:controls>
|
||||
<SongListControls v-if="!isPhone || showingControls" @playAll="playAll" @playSelected="playSelected"/>
|
||||
</template>
|
||||
</ScreenHeader>
|
||||
<ScreenHeaderSkeleton v-else/>
|
||||
|
||||
<SongListSkeleton v-if="showSkeletons"/>
|
||||
<SongList
|
||||
v-else
|
||||
ref="songList"
|
||||
@sort="sort"
|
||||
@press:enter="onPressEnter"
|
||||
@scroll-breakpoint="onScrollBreakpoint"
|
||||
@scrolled-to-end="fetch"
|
||||
/>
|
||||
|
||||
<ScreenEmptyState v-if="!songs.length && !loading">
|
||||
<template v-slot:icon>
|
||||
<icon :icon="faTags"/>
|
||||
</template>
|
||||
|
||||
No songs in this genre.
|
||||
</ScreenEmptyState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { faTags } from '@fortawesome/free-solid-svg-icons'
|
||||
import { eventBus, logger, pluralize, requireInjection, secondsToHis } from '@/utils'
|
||||
import { DialogBoxKey, RouterKey } from '@/symbols'
|
||||
import { useSongList } from '@/composables'
|
||||
import { genreStore, songStore } from '@/stores'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
|
||||
import { playbackService } from '@/services'
|
||||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
ThumbnailStack,
|
||||
headerLayout,
|
||||
songs,
|
||||
songList,
|
||||
thumbnails,
|
||||
showingControls,
|
||||
isPhone,
|
||||
onPressEnter,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(ref<Song[]>([]), 'Playlist')
|
||||
|
||||
const router = requireInjection(RouterKey)
|
||||
const dialog = requireInjection(DialogBoxKey)
|
||||
|
||||
let sortField: SongListSortField = 'title'
|
||||
let sortOrder: SortOrder = 'asc'
|
||||
|
||||
const randomSongCount = 500
|
||||
const name = ref<string | null>(null)
|
||||
const genre = ref<Genre | null>(null)
|
||||
const loading = ref(false)
|
||||
const page = ref<number | null>(1)
|
||||
|
||||
const moreSongsAvailable = computed(() => page.value !== null)
|
||||
const showSkeletons = computed(() => loading.value && songs.value.length === 0)
|
||||
const duration = computed(() => secondsToHis(genre.value?.length ?? 0))
|
||||
|
||||
const sort = async (field: SongListSortField, order: SortOrder) => {
|
||||
page.value = 1
|
||||
songs.value = []
|
||||
sortField = field
|
||||
sortOrder = order
|
||||
|
||||
await fetch()
|
||||
}
|
||||
|
||||
const fetch = async () => {
|
||||
if (!moreSongsAvailable.value || loading.value) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
let fetched
|
||||
|
||||
[genre.value, fetched] = await Promise.all([
|
||||
genreStore.fetchOne(name.value!),
|
||||
songStore.paginateForGenre(name.value!, sortField, sortOrder, page.value!)
|
||||
])
|
||||
|
||||
page.value = fetched.nextPage
|
||||
songs.value.push(...fetched.songs)
|
||||
} catch (e) {
|
||||
dialog.value.error('Failed to fetch genre details or genre was not found.')
|
||||
logger.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
genre.value = null
|
||||
page.value = 1
|
||||
songs.value = []
|
||||
|
||||
await fetch()
|
||||
}
|
||||
|
||||
const getNameFromRoute = () => {
|
||||
let param = router.$currentRoute.value.params?.name
|
||||
return param ? decodeURI(param) : null
|
||||
}
|
||||
|
||||
router.onRouteChanged(route => {
|
||||
if (route.screen !== 'Genre') return
|
||||
name.value = getNameFromRoute()
|
||||
})
|
||||
|
||||
const playAll = async () => {
|
||||
if (!genre.value) return
|
||||
|
||||
// we ignore the queueAndPlay's await to avoid blocking the UI
|
||||
if (genre.value!.song_count <= randomSongCount) {
|
||||
playbackService.queueAndPlay(songs.value, true)
|
||||
} else {
|
||||
playbackService.queueAndPlay(await songStore.fetchRandomForGenre(genre.value!, randomSongCount))
|
||||
}
|
||||
|
||||
router.go('queue')
|
||||
}
|
||||
|
||||
onMounted(() => (name.value = getNameFromRoute()))
|
||||
|
||||
watch(name, async () => name.value && await refresh())
|
||||
|
||||
// We can't really tell how/if the genres have been updated, so we just refresh the list
|
||||
eventBus.on('SONGS_UPDATED', async () => await refresh())
|
||||
</script>
|
|
@ -87,7 +87,7 @@ const loading = ref(false)
|
|||
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
|
||||
|
||||
const playAll = async (shuffle = true) => {
|
||||
await playbackService.queueAndPlay(songs.value, shuffle)
|
||||
playbackService.queueAndPlay(songs.value, shuffle)
|
||||
router.go('queue')
|
||||
}
|
||||
|
||||
|
|
|
@ -72,7 +72,7 @@ const playOrQueue = async (event: KeyboardEvent) => {
|
|||
return
|
||||
}
|
||||
|
||||
await playbackService.queueAndPlay(songs)
|
||||
playbackService.queueAndPlay(songs)
|
||||
router.go('queue')
|
||||
}
|
||||
|
||||
|
|
|
@ -47,7 +47,7 @@ const initiatePlayback = async () => {
|
|||
break
|
||||
}
|
||||
|
||||
await playbackService.queueAndPlay(songs)
|
||||
playbackService.queueAndPlay(songs)
|
||||
router.go('queue')
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<article class="skeleton pulse" :style="{ width: `${width}px` }">
|
||||
<span class="name"></span>
|
||||
<span class="count pulse"/>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const width = Math.random() * 100 + 80
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton {
|
||||
display: flex;
|
||||
border-radius: 999rem;
|
||||
overflow: hidden;
|
||||
height: 28px;
|
||||
margin: .4rem;
|
||||
padding: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.count {
|
||||
aspect-ratio: 1.2/1;
|
||||
border-radius: 9999rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -43,7 +43,12 @@ export const useSongList = (songs: Ref<Song[]>, screen: ScreenName, config: Part
|
|||
})
|
||||
|
||||
const getSongsToPlay = (): Song[] => songList.value.getAllSongsWithSort()
|
||||
const playAll = (shuffle: boolean) => playbackService.queueAndPlay(getSongsToPlay(), shuffle)
|
||||
|
||||
const playAll = (shuffle: boolean) => {
|
||||
playbackService.queueAndPlay(getSongsToPlay(), shuffle)
|
||||
router.go('queue')
|
||||
}
|
||||
|
||||
const playSelected = (shuffle: boolean) => playbackService.queueAndPlay(selectedSongs.value, shuffle)
|
||||
|
||||
const onPressEnter = async (event: KeyboardEvent) => {
|
||||
|
|
|
@ -82,6 +82,14 @@ export const routes: Route[] = [
|
|||
path: '/playlist/(?<id>\\d+)',
|
||||
screen: 'Playlist'
|
||||
},
|
||||
{
|
||||
path: '/genres',
|
||||
screen: 'Genres'
|
||||
},
|
||||
{
|
||||
path: '/genres/(?<name>\.+)',
|
||||
screen: 'Genre'
|
||||
},
|
||||
{
|
||||
path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
|
||||
screen: 'Queue',
|
||||
|
|
|
@ -333,7 +333,7 @@ new class extends UnitTestCase {
|
|||
const shuffleMock = this.mock(lodash, 'shuffle')
|
||||
this.setReadOnlyProperty(queueStore, 'first', firstSongInQueue)
|
||||
|
||||
await playbackService.queueAndPlay(songs)
|
||||
playbackService.queueAndPlay(songs)
|
||||
await nextTick()
|
||||
|
||||
expect(shuffleMock).not.toHaveBeenCalled()
|
||||
|
@ -350,7 +350,7 @@ new class extends UnitTestCase {
|
|||
this.setReadOnlyProperty(queueStore, 'first', firstSongInQueue)
|
||||
const shuffleMock = this.mock(lodash, 'shuffle', shuffledSongs)
|
||||
|
||||
await playbackService.queueAndPlay(songs, true)
|
||||
playbackService.queueAndPlay(songs, true)
|
||||
await nextTick()
|
||||
|
||||
expect(shuffleMock).toHaveBeenCalledWith(songs)
|
||||
|
|
24
resources/assets/js/stores/genreStore.spec.ts
Normal file
24
resources/assets/js/stores/genreStore.spec.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { http } from '@/services'
|
||||
import { genreStore } from '@/stores/genreStore'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it('fetches all genres', async () => {
|
||||
const genres = factory<Genre>('genre', 3)
|
||||
this.mock(http, 'get').mockResolvedValue(genres)
|
||||
|
||||
expect(await genreStore.fetchAll()).toEqual(genres)
|
||||
})
|
||||
|
||||
it('fetches a single genre', async () => {
|
||||
const genre = factory<Genre>('genre')
|
||||
this.mock(http, 'get').mockResolvedValue(genre)
|
||||
|
||||
expect(await genreStore.fetchOne(genre.name)).toEqual(genre)
|
||||
expect(http.get).toHaveBeenCalledWith(`genres/${genre.name}`)
|
||||
})
|
||||
}
|
||||
}
|
6
resources/assets/js/stores/genreStore.ts
Normal file
6
resources/assets/js/stores/genreStore.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { http } from '@/services'
|
||||
|
||||
export const genreStore = {
|
||||
fetchAll: async () => await http.get<Genre[]>('genres'),
|
||||
fetchOne: async (name: string) => await http.get<Genre>(`genres/${name}`)
|
||||
}
|
|
@ -14,3 +14,4 @@ export * from './settingStore'
|
|||
export * from './songStore'
|
||||
export * from './themeStore'
|
||||
export * from './userStore'
|
||||
export * from './genreStore'
|
||||
|
|
|
@ -253,5 +253,43 @@ new class extends UnitTestCase {
|
|||
expect(syncMock).toHaveBeenCalledWith(songs)
|
||||
expect(songStore.state.songs).toEqual(reactive(songs))
|
||||
})
|
||||
|
||||
it('paginates for genre', async () => {
|
||||
const songs = factory<Song>('song', 3)
|
||||
const reactiveSongs = reactive(songs)
|
||||
|
||||
const getMock = this.mock(http, 'get').mockResolvedValueOnce({
|
||||
data: songs,
|
||||
links: {
|
||||
next: 'http://test/api/v1/songs?page=3'
|
||||
},
|
||||
meta: {
|
||||
current_page: 2
|
||||
}
|
||||
})
|
||||
|
||||
const syncMock = this.mock(songStore, 'syncWithVault', reactiveSongs)
|
||||
|
||||
expect(await songStore.paginateForGenre('foo', 'title', 'desc', 2)).toEqual({
|
||||
songs: reactiveSongs,
|
||||
nextPage: 3
|
||||
})
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('genres/foo/songs?page=2&sort=title&order=desc')
|
||||
expect(syncMock).toHaveBeenCalledWith(songs)
|
||||
})
|
||||
|
||||
it('fetches random songs for genre', async () => {
|
||||
const songs = factory<Song>('song', 3)
|
||||
const reactiveSongs = reactive(songs)
|
||||
|
||||
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
|
||||
const syncMock = this.mock(songStore, 'syncWithVault', reactiveSongs)
|
||||
|
||||
expect(await songStore.fetchRandomForGenre('foo')).toEqual(reactiveSongs)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('genres/foo/songs/random?limit=500')
|
||||
expect(syncMock).toHaveBeenCalledWith(songs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -170,6 +170,26 @@ export const songStore = {
|
|||
return uniqBy(songs, 'id')
|
||||
},
|
||||
|
||||
async paginateForGenre (genre: Genre | string, sortField: SongListSortField, sortOrder: SortOrder, page: number) {
|
||||
const name = typeof genre === 'string' ? genre : genre.name
|
||||
|
||||
const resource = await http.get<PaginatorResource>(
|
||||
`genres/${name}/songs?page=${page}&sort=${sortField}&order=${sortOrder}`
|
||||
)
|
||||
|
||||
const songs = this.syncWithVault(resource.data)
|
||||
|
||||
return {
|
||||
songs,
|
||||
nextPage: resource.links.next ? ++resource.meta.current_page : null
|
||||
}
|
||||
},
|
||||
|
||||
async fetchRandomForGenre (genre: Genre | string, limit = 500) {
|
||||
const name = typeof genre === 'string' ? genre : genre.name
|
||||
return this.syncWithVault(await http.get<Song[]>(`genres/${name}/songs/random?limit=${limit}`))
|
||||
},
|
||||
|
||||
async paginate (sortField: SongListSortField, sortOrder: SortOrder, page: number) {
|
||||
const resource = await http.get<PaginatorResource>(
|
||||
`songs?page=${page}&sort=${sortField}&order=${sortOrder}`
|
||||
|
|
9
resources/assets/js/types.d.ts
vendored
9
resources/assets/js/types.d.ts
vendored
|
@ -299,6 +299,8 @@ declare type ScreenName =
|
|||
| 'Profile'
|
||||
| 'Album'
|
||||
| 'Artist'
|
||||
| 'Genres'
|
||||
| 'Genre'
|
||||
| 'Playlist'
|
||||
| 'Upload'
|
||||
| 'Search.Excerpt'
|
||||
|
@ -376,4 +378,11 @@ type ToastMessage = {
|
|||
timeout: number // seconds
|
||||
}
|
||||
|
||||
type Genre = {
|
||||
type: 'genres'
|
||||
name: string
|
||||
song_count: number
|
||||
length: number
|
||||
}
|
||||
|
||||
type ExtraPanelTab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'
|
||||
|
|
|
@ -240,6 +240,10 @@ label {
|
|||
&thin {
|
||||
font-weight: var(--font-weight-thin) !important;
|
||||
}
|
||||
|
||||
&bold {
|
||||
font-weight: var(--font-weight-bold) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.d- {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.skeleton {
|
||||
.pulse {
|
||||
.pulse, &.pulse {
|
||||
animation: skeleton-pulse 2s infinite;
|
||||
background-color: rgba(255, 255, 255, .05);
|
||||
}
|
||||
|
|
|
@ -12,6 +12,9 @@ use App\Http\Controllers\V6\API\ExcerptSearchController;
|
|||
use App\Http\Controllers\V6\API\FavoriteSongController;
|
||||
use App\Http\Controllers\V6\API\FetchAlbumInformationController;
|
||||
use App\Http\Controllers\V6\API\FetchArtistInformationController;
|
||||
use App\Http\Controllers\V6\API\FetchRandomSongsInGenreController;
|
||||
use App\Http\Controllers\V6\API\GenreController;
|
||||
use App\Http\Controllers\V6\API\GenreSongController;
|
||||
use App\Http\Controllers\V6\API\OverviewController;
|
||||
use App\Http\Controllers\V6\API\PlayCountController;
|
||||
use App\Http\Controllers\V6\API\PlaylistController;
|
||||
|
@ -55,6 +58,10 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
|||
Route::get('songs/favorite', [FavoriteSongController::class, 'index']);
|
||||
Route::delete('songs', DeleteSongsController::class);
|
||||
|
||||
Route::apiResource('genres', GenreController::class);
|
||||
Route::get('genres/{genre}/songs', GenreSongController::class);
|
||||
Route::get('genres/{genre}/songs/random', FetchRandomSongsInGenreController::class);
|
||||
|
||||
Route::apiResource('users', UserController::class);
|
||||
|
||||
Route::get('search', ExcerptSearchController::class);
|
||||
|
|
59
tests/Feature/V6/GenreTest.php
Normal file
59
tests/Feature/V6/GenreTest.php
Normal file
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\V6;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Values\Genre;
|
||||
|
||||
class GenreTest extends TestCase
|
||||
{
|
||||
private const JSON_STRUCTURE = [
|
||||
'type',
|
||||
'name',
|
||||
'song_count',
|
||||
'length',
|
||||
];
|
||||
|
||||
public function testGetAllGenres(): void
|
||||
{
|
||||
Song::factory()->count(5)->create(['genre' => 'Rock']);
|
||||
Song::factory()->count(2)->create(['genre' => 'Pop']);
|
||||
Song::factory()->count(10)->create(['genre' => '']);
|
||||
|
||||
$this->getAs('api/genres')
|
||||
->assertJsonStructure(['*' => self::JSON_STRUCTURE])
|
||||
->assertJsonFragment(['name' => 'Rock', 'song_count' => 5])
|
||||
->assertJsonFragment(['name' => 'Pop', 'song_count' => 2])
|
||||
->assertJsonFragment(['name' => Genre::NO_GENRE, 'song_count' => 10]);
|
||||
}
|
||||
|
||||
public function testGetOneGenre(): void
|
||||
{
|
||||
Song::factory()->count(5)->create(['genre' => 'Rock']);
|
||||
|
||||
$this->getAs('api/genres/Rock')
|
||||
->assertJsonStructure(self::JSON_STRUCTURE)
|
||||
->assertJsonFragment(['name' => 'Rock', 'song_count' => 5]);
|
||||
}
|
||||
|
||||
public function testGetNonExistingGenreThrowsNotFound(): void
|
||||
{
|
||||
$this->getAs('api/genres/NonExistingGenre')->assertNotFound();
|
||||
}
|
||||
|
||||
public function testPaginateSongsInGenre(): void
|
||||
{
|
||||
Song::factory()->count(5)->create(['genre' => 'Rock']);
|
||||
|
||||
$this->getAs('api/genres/Rock/songs')
|
||||
->assertJsonStructure(SongTest::JSON_COLLECTION_STRUCTURE);
|
||||
}
|
||||
|
||||
public function testGetRandomSongsInGenre(): void
|
||||
{
|
||||
Song::factory()->count(5)->create(['genre' => 'Rock']);
|
||||
|
||||
$this->getAs('api/genres/Rock/songs/random?limit=500')
|
||||
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
|
||||
}
|
||||
}
|
|
@ -30,7 +30,7 @@ class SongTest extends TestCase
|
|||
'created_at',
|
||||
];
|
||||
|
||||
private const JSON_COLLECTION_STRUCTURE = [
|
||||
public const JSON_COLLECTION_STRUCTURE = [
|
||||
'data' => [
|
||||
'*' => self::JSON_STRUCTURE,
|
||||
],
|
||||
|
|
Loading…
Reference in a new issue