diff --git a/app/Http/Controllers/V6/API/FetchRandomSongsInGenreController.php b/app/Http/Controllers/V6/API/FetchRandomSongsInGenreController.php new file mode 100644 index 00000000..5a081608 --- /dev/null +++ b/app/Http/Controllers/V6/API/FetchRandomSongsInGenreController.php @@ -0,0 +1,22 @@ +getRandomByGenre($request->genre, $request->limit, $user)); + } +} diff --git a/app/Http/Controllers/V6/API/GenreController.php b/app/Http/Controllers/V6/API/GenreController.php new file mode 100644 index 00000000..3b8d19b0 --- /dev/null +++ b/app/Http/Controllers/V6/API/GenreController.php @@ -0,0 +1,31 @@ +repository->getAll()); + } + + public function show(string $name) + { + $genre = $this->repository->getOne($name); + + if (!$genre) { + abort(Response::HTTP_NOT_FOUND); + } + + return GenreResource::make($genre); + } +} diff --git a/app/Http/Controllers/V6/API/GenreSongController.php b/app/Http/Controllers/V6/API/GenreSongController.php new file mode 100644 index 00000000..e3fd68e1 --- /dev/null +++ b/app/Http/Controllers/V6/API/GenreSongController.php @@ -0,0 +1,37 @@ +getByGenre( + $genre, + $request->sort ?: 'songs.title', + $request->order ?: 'asc', + $user + ) + ); + } +} diff --git a/app/Http/Requests/V6/API/FetchRandomSongsInGenreRequest.php b/app/Http/Requests/V6/API/FetchRandomSongsInGenreRequest.php new file mode 100644 index 00000000..4f4575db --- /dev/null +++ b/app/Http/Requests/V6/API/FetchRandomSongsInGenreRequest.php @@ -0,0 +1,13 @@ + */ + public function toArray($request): array + { + return [ + 'type' => 'genres', + 'name' => $this->genre->name, + 'song_count' => $this->genre->songCount, + 'length' => $this->genre->length, + ]; + } +} diff --git a/app/Repositories/GenreRepository.php b/app/Repositories/GenreRepository.php new file mode 100644 index 00000000..dbcad709 --- /dev/null +++ b/app/Repositories/GenreRepository.php @@ -0,0 +1,44 @@ + */ + 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; + } +} diff --git a/app/Repositories/SongRepository.php b/app/Repositories/SongRepository.php index 1ab0e368..85683918 100644 --- a/app/Repositories/SongRepository.php +++ b/app/Repositories/SongRepository.php @@ -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 */ public function getForQueue( string $sortColumn, @@ -202,4 +220,15 @@ class SongRepository extends Repository return $query; } + + /** @return Collection|array */ + 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(); + } } diff --git a/app/Values/Genre.php b/app/Values/Genre.php new file mode 100644 index 00000000..cb1e4346 --- /dev/null +++ b/app/Values/Genre.php @@ -0,0 +1,17 @@ + { + 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 }) + } +} diff --git a/resources/assets/js/__tests__/factory/index.ts b/resources/assets/js/__tests__/factory/index.ts index c9c208ad..ec07cab9 100644 --- a/resources/assets/js/__tests__/factory/index.ts +++ b/resources/assets/js/__tests__/factory/index.ts @@ -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)) diff --git a/resources/assets/js/components/album/AlbumCard.vue b/resources/assets/js/components/album/AlbumCard.vue index 0042b581..2ebd76d9 100644 --- a/resources/assets/js/components/album/AlbumCard.vue +++ b/resources/assets/js/components/album/AlbumCard.vue @@ -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') } diff --git a/resources/assets/js/components/album/AlbumContextMenu.vue b/resources/assets/js/components/album/AlbumContextMenu.vue index 3bdcd14d..32a3d5ec 100644 --- a/resources/assets/js/components/album/AlbumContextMenu.vue +++ b/resources/assets/js/components/album/AlbumContextMenu.vue @@ -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') }) diff --git a/resources/assets/js/components/album/AlbumInfo.vue b/resources/assets/js/components/album/AlbumInfo.vue index ecfe1cee..8a43cdf2 100644 --- a/resources/assets/js/components/album/AlbumInfo.vue +++ b/resources/assets/js/components/album/AlbumInfo.vue @@ -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') } diff --git a/resources/assets/js/components/artist/ArtistCard.vue b/resources/assets/js/components/artist/ArtistCard.vue index 6e08e4ec..adaf3a42 100644 --- a/resources/assets/js/components/artist/ArtistCard.vue +++ b/resources/assets/js/components/artist/ArtistCard.vue @@ -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') } diff --git a/resources/assets/js/components/artist/ArtistContextMenu.vue b/resources/assets/js/components/artist/ArtistContextMenu.vue index edd4dc88..f507a45e 100644 --- a/resources/assets/js/components/artist/ArtistContextMenu.vue +++ b/resources/assets/js/components/artist/ArtistContextMenu.vue @@ -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') }) diff --git a/resources/assets/js/components/artist/ArtistInfo.vue b/resources/assets/js/components/artist/ArtistInfo.vue index 6b9e5ed6..5af72d7c 100644 --- a/resources/assets/js/components/artist/ArtistInfo.vue +++ b/resources/assets/js/components/artist/ArtistInfo.vue @@ -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') } diff --git a/resources/assets/js/components/layout/main-wrapper/MainContent.vue b/resources/assets/js/components/layout/main-wrapper/MainContent.vue index b255da41..6e24e2ae 100644 --- a/resources/assets/js/components/layout/main-wrapper/MainContent.vue +++ b/resources/assets/js/components/layout/main-wrapper/MainContent.vue @@ -18,7 +18,9 @@ + + @@ -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')) diff --git a/resources/assets/js/components/layout/main-wrapper/Sidebar.vue b/resources/assets/js/components/layout/main-wrapper/Sidebar.vue index 1db5231c..6e5a5f3c 100644 --- a/resources/assets/js/components/layout/main-wrapper/Sidebar.vue +++ b/resources/assets/js/components/layout/main-wrapper/Sidebar.vue @@ -40,6 +40,12 @@ Artists +
  • + + + Genres + +
  • @@ -85,6 +91,7 @@ import { faListOl, faMicrophone, faMusic, + faTags, faTools, faUpload, faUsers diff --git a/resources/assets/js/components/playlist/PlaylistFolderContextMenu.vue b/resources/assets/js/components/playlist/PlaylistFolderContextMenu.vue index 86c0c79f..494a4e93 100644 --- a/resources/assets/js/components/playlist/PlaylistFolderContextMenu.vue +++ b/resources/assets/js/components/playlist/PlaylistFolderContextMenu.vue @@ -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') }) diff --git a/resources/assets/js/components/screens/AlbumScreen.spec.ts b/resources/assets/js/components/screens/AlbumScreen.spec.ts index 50862cfb..ce5d0dbc 100644 --- a/resources/assets/js/components/screens/AlbumScreen.spec.ts +++ b/resources/assets/js/components/screens/AlbumScreen.spec.ts @@ -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', { diff --git a/resources/assets/js/components/screens/GenreListScreen.spec.ts b/resources/assets/js/components/screens/GenreListScreen.spec.ts new file mode 100644 index 00000000..c54e67f9 --- /dev/null +++ b/resources/assets/js/components/screens/GenreListScreen.spec.ts @@ -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', { name: 'Rock', song_count: 10 }), + factory('genre', { name: 'Pop', song_count: 20 }), + factory('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`)) + }) + }) + } +} diff --git a/resources/assets/js/components/screens/GenreListScreen.vue b/resources/assets/js/components/screens/GenreListScreen.vue new file mode 100644 index 00000000..727d41d0 --- /dev/null +++ b/resources/assets/js/components/screens/GenreListScreen.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/resources/assets/js/components/screens/GenreScreen.spec.ts b/resources/assets/js/components/screens/GenreScreen.spec.ts new file mode 100644 index 00000000..18049dc8 --- /dev/null +++ b/resources/assets/js/components/screens/GenreScreen.spec.ts @@ -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') + + const fetchGenreMock = this.mock(genreStore, 'fetchOne').mockResolvedValue(genre) + const paginateMock = this.mock(songStore, 'paginateForGenre').mockResolvedValue({ + nextPage: 2, + songs: songs || factory('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', { song_count: 10 }) + const songs = factory('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', { song_count: 501 }) + const songs = factory('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) + }) + }) + } +} diff --git a/resources/assets/js/components/screens/GenreScreen.vue b/resources/assets/js/components/screens/GenreScreen.vue new file mode 100644 index 00000000..24d23da8 --- /dev/null +++ b/resources/assets/js/components/screens/GenreScreen.vue @@ -0,0 +1,157 @@ + + + diff --git a/resources/assets/js/components/screens/QueueScreen.vue b/resources/assets/js/components/screens/QueueScreen.vue index 8a909ba3..b1c060f9 100644 --- a/resources/assets/js/components/screens/QueueScreen.vue +++ b/resources/assets/js/components/screens/QueueScreen.vue @@ -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') } diff --git a/resources/assets/js/components/ui/AlbumArtistThumbnail.vue b/resources/assets/js/components/ui/AlbumArtistThumbnail.vue index e37aef97..9682e1a1 100644 --- a/resources/assets/js/components/ui/AlbumArtistThumbnail.vue +++ b/resources/assets/js/components/ui/AlbumArtistThumbnail.vue @@ -72,7 +72,7 @@ const playOrQueue = async (event: KeyboardEvent) => { return } - await playbackService.queueAndPlay(songs) + playbackService.queueAndPlay(songs) router.go('queue') } diff --git a/resources/assets/js/components/ui/FooterPlayButton.vue b/resources/assets/js/components/ui/FooterPlayButton.vue index 6f0ee289..13780a0e 100644 --- a/resources/assets/js/components/ui/FooterPlayButton.vue +++ b/resources/assets/js/components/ui/FooterPlayButton.vue @@ -47,7 +47,7 @@ const initiatePlayback = async () => { break } - await playbackService.queueAndPlay(songs) + playbackService.queueAndPlay(songs) router.go('queue') } diff --git a/resources/assets/js/components/ui/skeletons/GenreItemSkeleton.vue b/resources/assets/js/components/ui/skeletons/GenreItemSkeleton.vue new file mode 100644 index 00000000..dd99afeb --- /dev/null +++ b/resources/assets/js/components/ui/skeletons/GenreItemSkeleton.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/resources/assets/js/composables/useSongList.ts b/resources/assets/js/composables/useSongList.ts index cdfcd5d3..1fa5d70c 100644 --- a/resources/assets/js/composables/useSongList.ts +++ b/resources/assets/js/composables/useSongList.ts @@ -43,7 +43,12 @@ export const useSongList = (songs: Ref, 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) => { diff --git a/resources/assets/js/config/routes.ts b/resources/assets/js/config/routes.ts index 075408d3..3c6a2cf6 100644 --- a/resources/assets/js/config/routes.ts +++ b/resources/assets/js/config/routes.ts @@ -82,6 +82,14 @@ export const routes: Route[] = [ path: '/playlist/(?\\d+)', screen: 'Playlist' }, + { + path: '/genres', + screen: 'Genres' + }, + { + path: '/genres/(?\.+)', + screen: 'Genre' + }, { path: '/song/(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', screen: 'Queue', diff --git a/resources/assets/js/services/playbackService.spec.ts b/resources/assets/js/services/playbackService.spec.ts index 3850e2a3..621d4147 100644 --- a/resources/assets/js/services/playbackService.spec.ts +++ b/resources/assets/js/services/playbackService.spec.ts @@ -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) diff --git a/resources/assets/js/stores/genreStore.spec.ts b/resources/assets/js/stores/genreStore.spec.ts new file mode 100644 index 00000000..df0496e8 --- /dev/null +++ b/resources/assets/js/stores/genreStore.spec.ts @@ -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', 3) + this.mock(http, 'get').mockResolvedValue(genres) + + expect(await genreStore.fetchAll()).toEqual(genres) + }) + + it('fetches a single genre', async () => { + const genre = factory('genre') + this.mock(http, 'get').mockResolvedValue(genre) + + expect(await genreStore.fetchOne(genre.name)).toEqual(genre) + expect(http.get).toHaveBeenCalledWith(`genres/${genre.name}`) + }) + } +} diff --git a/resources/assets/js/stores/genreStore.ts b/resources/assets/js/stores/genreStore.ts new file mode 100644 index 00000000..12dc042a --- /dev/null +++ b/resources/assets/js/stores/genreStore.ts @@ -0,0 +1,6 @@ +import { http } from '@/services' + +export const genreStore = { + fetchAll: async () => await http.get('genres'), + fetchOne: async (name: string) => await http.get(`genres/${name}`) +} diff --git a/resources/assets/js/stores/index.ts b/resources/assets/js/stores/index.ts index 96cef3bb..e93c310d 100644 --- a/resources/assets/js/stores/index.ts +++ b/resources/assets/js/stores/index.ts @@ -14,3 +14,4 @@ export * from './settingStore' export * from './songStore' export * from './themeStore' export * from './userStore' +export * from './genreStore' diff --git a/resources/assets/js/stores/songStore.spec.ts b/resources/assets/js/stores/songStore.spec.ts index 60abb2c8..51774aba 100644 --- a/resources/assets/js/stores/songStore.spec.ts +++ b/resources/assets/js/stores/songStore.spec.ts @@ -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', 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', 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) + }) } } diff --git a/resources/assets/js/stores/songStore.ts b/resources/assets/js/stores/songStore.ts index d4336e96..3e6cef16 100644 --- a/resources/assets/js/stores/songStore.ts +++ b/resources/assets/js/stores/songStore.ts @@ -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( + `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(`genres/${name}/songs/random?limit=${limit}`)) + }, + async paginate (sortField: SongListSortField, sortOrder: SortOrder, page: number) { const resource = await http.get( `songs?page=${page}&sort=${sortField}&order=${sortOrder}` diff --git a/resources/assets/js/types.d.ts b/resources/assets/js/types.d.ts index 3d336cc6..e1a1f1cc 100644 --- a/resources/assets/js/types.d.ts +++ b/resources/assets/js/types.d.ts @@ -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' diff --git a/resources/assets/sass/partials/_shared.scss b/resources/assets/sass/partials/_shared.scss index c430df81..7f5a74f1 100644 --- a/resources/assets/sass/partials/_shared.scss +++ b/resources/assets/sass/partials/_shared.scss @@ -240,6 +240,10 @@ label { &thin { font-weight: var(--font-weight-thin) !important; } + + &bold { + font-weight: var(--font-weight-bold) !important; + } } .d- { diff --git a/resources/assets/sass/partials/_skeleton.scss b/resources/assets/sass/partials/_skeleton.scss index 38ff3326..f80f94c5 100644 --- a/resources/assets/sass/partials/_skeleton.scss +++ b/resources/assets/sass/partials/_skeleton.scss @@ -1,5 +1,5 @@ .skeleton { - .pulse { + .pulse, &.pulse { animation: skeleton-pulse 2s infinite; background-color: rgba(255, 255, 255, .05); } diff --git a/routes/api.v6.php b/routes/api.v6.php index affebe63..06994081 100644 --- a/routes/api.v6.php +++ b/routes/api.v6.php @@ -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); diff --git a/tests/Feature/V6/GenreTest.php b/tests/Feature/V6/GenreTest.php new file mode 100644 index 00000000..abb567fb --- /dev/null +++ b/tests/Feature/V6/GenreTest.php @@ -0,0 +1,59 @@ +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]); + } +} diff --git a/tests/Feature/V6/SongTest.php b/tests/Feature/V6/SongTest.php index e8743001..3f216dd6 100644 --- a/tests/Feature/V6/SongTest.php +++ b/tests/Feature/V6/SongTest.php @@ -30,7 +30,7 @@ class SongTest extends TestCase 'created_at', ]; - private const JSON_COLLECTION_STRUCTURE = [ + public const JSON_COLLECTION_STRUCTURE = [ 'data' => [ '*' => self::JSON_STRUCTURE, ],