From 9789933991bd8f9034fca618546d314af8ab5145 Mon Sep 17 00:00:00 2001 From: Phan An Date: Sun, 24 Jul 2022 13:47:18 +0200 Subject: [PATCH] feat(test): add songStore tests --- .../assets/js/__tests__/factory/index.ts | 4 +- .../__tests__/factory/interactionFactory.ts | 10 + resources/assets/js/__tests__/setup.ts | 2 + .../components/screens/AllSongsScreen.spec.ts | 2 +- .../js/components/screens/AllSongsScreen.vue | 2 +- .../js/components/song/SongEditForm.vue | 16 +- resources/assets/js/stores/albumStore.ts | 15 +- resources/assets/js/stores/artistStore.ts | 15 +- resources/assets/js/stores/songStore.spec.ts | 277 +++++++++++++++++- resources/assets/js/stores/songStore.ts | 102 +++---- resources/assets/js/types.d.ts | 1 + routes/api.base.php | 4 + 12 files changed, 343 insertions(+), 107 deletions(-) create mode 100644 resources/assets/js/__tests__/factory/interactionFactory.ts diff --git a/resources/assets/js/__tests__/factory/index.ts b/resources/assets/js/__tests__/factory/index.ts index b9bd293d..c4865c80 100644 --- a/resources/assets/js/__tests__/factory/index.ts +++ b/resources/assets/js/__tests__/factory/index.ts @@ -1,7 +1,8 @@ import factory from 'factoria' import artistFactory, { states as artistStates } from '@/__tests__/factory/artistFactory' import songFactory, { states as songStates } from '@/__tests__/factory/songFactory' -import albumFactory, { states as albumStates } from './albumFactory' +import albumFactory, { states as albumStates } from '@/__tests__/factory/albumFactory' +import interactionFactory from '@/__tests__/factory/interactionFactory' import smartPlaylistRuleFactory from '@/__tests__/factory/smartPlaylistRuleFactory' import smartPlaylistRuleGroupFactory from '@/__tests__/factory/smartPlaylistRuleGroupFactory' import playlistFactory, { states as playlistStates } from '@/__tests__/factory/playlistFactory' @@ -18,6 +19,7 @@ export default factory .define('album-track', faker => albumTrackFactory(faker)) .define('album-info', faker => albumInfoFactory(faker)) .define('song', faker => songFactory(faker), songStates) + .define('interaction', faker => interactionFactory(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/__tests__/factory/interactionFactory.ts b/resources/assets/js/__tests__/factory/interactionFactory.ts new file mode 100644 index 00000000..4e0a4f57 --- /dev/null +++ b/resources/assets/js/__tests__/factory/interactionFactory.ts @@ -0,0 +1,10 @@ +import crypto from 'crypto-random-string' +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): Interaction => ({ + type: 'interactions', + id: faker.datatype.number({ min: 1 }), + song_id: crypto(32), + liked: faker.datatype.boolean(), + play_count: faker.datatype.number({ min: 1 }) +}) diff --git a/resources/assets/js/__tests__/setup.ts b/resources/assets/js/__tests__/setup.ts index 33610c83..f69d9ffe 100644 --- a/resources/assets/js/__tests__/setup.ts +++ b/resources/assets/js/__tests__/setup.ts @@ -13,3 +13,5 @@ global.ResizeObserver = global.ResizeObserver || window.HTMLMediaElement.prototype.load = vi.fn() window.HTMLMediaElement.prototype.play = vi.fn() window.HTMLMediaElement.prototype.pause = vi.fn() + +window.BASE_URL = 'https://koel.test/' diff --git a/resources/assets/js/components/screens/AllSongsScreen.spec.ts b/resources/assets/js/components/screens/AllSongsScreen.spec.ts index b381726c..6addcc25 100644 --- a/resources/assets/js/components/screens/AllSongsScreen.spec.ts +++ b/resources/assets/js/components/screens/AllSongsScreen.spec.ts @@ -13,7 +13,7 @@ new class extends UnitTestCase { commonStore.state.song_count = 420 commonStore.state.song_length = 123_456 songStore.state.songs = factory('song', 20) - const fetchMock = this.mock(songStore, 'fetch').mockResolvedValue(2) + const fetchMock = this.mock(songStore, 'paginate').mockResolvedValue(2) const rendered = this.render(AllSongsScreen, { global: { diff --git a/resources/assets/js/components/screens/AllSongsScreen.vue b/resources/assets/js/components/screens/AllSongsScreen.vue index e56e4fb5..9a71364b 100644 --- a/resources/assets/js/components/screens/AllSongsScreen.vue +++ b/resources/assets/js/components/screens/AllSongsScreen.vue @@ -84,7 +84,7 @@ const fetchSongs = async () => { if (!moreSongsAvailable.value || loading) return loading = true - page.value = await songStore.fetch(sortField, sortOrder, page.value!) + page.value = await songStore.paginate(sortField, sortOrder, page.value!) loading = false } diff --git a/resources/assets/js/components/song/SongEditForm.vue b/resources/assets/js/components/song/SongEditForm.vue index f795ef19..b40f45a8 100644 --- a/resources/assets/js/components/song/SongEditForm.vue +++ b/resources/assets/js/components/song/SongEditForm.vue @@ -173,22 +173,12 @@ import { computed, onMounted, reactive, ref } from 'vue' import { isEqual } from 'lodash' import { alerts, defaultCover, pluralize, requireInjection } from '@/utils' -import { songStore } from '@/stores' +import { songStore, SongUpdateData } from '@/stores' import { EditSongFormInitialTabKey, SongsKey } from '@/symbols' import Btn from '@/components/ui/Btn.vue' import SoundBar from '@/components/ui/SoundBar.vue' -type EditFormData = { - title?: string - artist_name?: string - album_name?: string - album_artist_name?: string - track?: number | null - disc?: number | null - lyrics?: string -} - const [initialTab] = requireInjection(EditSongFormInitialTabKey) const [songs] = requireInjection(SongsKey) @@ -200,7 +190,7 @@ const mutatedSongs = computed(() => songs.value) /** * In order not to mess up the original songs, we manually assign and manipulate their attributes. */ -const formData = reactive({ +const formData = reactive({ title: '', album_name: '', artist_name: '', @@ -223,7 +213,7 @@ const coverUrl = computed(() => allSongsAreInSameAlbum.value : defaultCover ) -const allSongsShareSameValue = (key: keyof EditFormData) => { +const allSongsShareSameValue = (key: keyof SongUpdateData) => { if (editingOnlyOneSong.value) return true return new Set(mutatedSongs.value.map(song => song[key])).size === 1 } diff --git a/resources/assets/js/stores/albumStore.ts b/resources/assets/js/stores/albumStore.ts index 677a1519..340970e9 100644 --- a/resources/assets/js/stores/albumStore.ts +++ b/resources/assets/js/stores/albumStore.ts @@ -1,13 +1,13 @@ -import { reactive } from 'vue' +import { reactive, UnwrapNestedRefs } from 'vue' import { differenceBy, merge, orderBy, take, unionBy } from 'lodash' import { Cache, httpService } from '@/services' -import { arrayify } from '@/utils' +import { arrayify, logger } from '@/utils' import { songStore } from '@/stores' const UNKNOWN_ALBUM_ID = 1 export const albumStore = { - vault: new Map(), + vault: new Map>(), state: reactive({ albums: [] @@ -64,8 +64,13 @@ export const albumStore = { let album = this.byId(id) if (!album) { - album = await Cache.resolve(['album', id], async () => await httpService.get(`albums/${id}`)) - this.syncWithVault(album) + try { + album = this.syncWithVault( + await Cache.resolve(['album', id], async () => await httpService.get(`albums/${id}`)) + )[0] + } catch (e) { + logger.error(e) + } } return album diff --git a/resources/assets/js/stores/artistStore.ts b/resources/assets/js/stores/artistStore.ts index a4701899..e409215f 100644 --- a/resources/assets/js/stores/artistStore.ts +++ b/resources/assets/js/stores/artistStore.ts @@ -1,13 +1,13 @@ -import { reactive } from 'vue' +import { reactive, UnwrapNestedRefs } from 'vue' import { differenceBy, orderBy, take, unionBy } from 'lodash' import { Cache, httpService } from '@/services' -import { arrayify } from '@/utils' +import { arrayify, logger } from '@/utils' const UNKNOWN_ARTIST_ID = 1 const VARIOUS_ARTISTS_ID = 2 export const artistStore = { - vault: new Map(), + vault: new Map>(), state: reactive({ artists: [] @@ -57,8 +57,13 @@ export const artistStore = { let artist = this.byId(id) if (!artist) { - artist = await Cache.resolve(['artist', id], async () => await httpService.get(`artists/${id}`)) - this.syncWithVault(artist) + try { + artist = this.syncWithVault( + await Cache.resolve(['artist', id], async () => await httpService.get(`artists/${id}`)) + )[0] + } catch (e) { + logger.error(e) + } } return artist diff --git a/resources/assets/js/stores/songStore.spec.ts b/resources/assets/js/stores/songStore.spec.ts index 9d7df886..4d2be172 100644 --- a/resources/assets/js/stores/songStore.spec.ts +++ b/resources/assets/js/stores/songStore.spec.ts @@ -1,27 +1,280 @@ +import isMobile from 'ismobilejs' import UnitTestCase from '@/__tests__/UnitTestCase' import { expect, it } from 'vitest' -import { songStore } from '@/stores/songStore' +import factory from '@/__tests__/factory' +import { authService, httpService } from '@/services' +import { + albumStore, + artistStore, + commonStore, + overviewStore, + preferenceStore, + songStore, + SongUpdateResult +} from '@/stores' +import { eventBus } from '@/utils' +import { reactive } from 'vue' new class extends UnitTestCase { + protected afterEach () { + super.afterEach(() => { + isMobile.any = false + preferenceStore.transcodeOnMobile = false + }) + } + protected test () { it('gets a song by ID', () => { - expect(songStore.byId('e6d3977f3ffa147801ca5d1fdf6fa55e')!.title).toBe('Like a rolling stone') + const song = reactive(factory('song', { id: 'foo' })) + songStore.vault.set('foo', reactive(song)) + songStore.vault.set('bar', reactive(factory('song', { id: 'bar' }))) + + expect(songStore.byId('foo')).toBe(song) }) - it('gets multiple songs by IDs', () => { - const songs = songStore.byIds(['e6d3977f3ffa147801ca5d1fdf6fa55e', 'aa16bbef6a9710eb9a0f41ecc534fad5']) - expect(songs[0].title).toBe('Like a rolling stone') - expect(songs[1].title).toBe('Knockin\' on heaven\'s door') + it('gets songs by IDs', () => { + const foo = reactive(factory('song', { id: 'foo' })) + const bar = reactive(factory('song', { id: 'bar' })) + songStore.vault.set('foo', foo) + songStore.vault.set('bar', bar) + songStore.vault.set('baz', reactive(factory('song', { id: 'baz' }))) + + expect(songStore.byIds(['foo', 'bar'])).toEqual([foo, bar]) }) - it('sets interaction status', () => { - const song = songStore.byId('cb7edeac1f097143e65b1b2cde102482')! - expect(song.liked).toBe(true) - expect(song.play_count).toBe(3) + it('gets formatted length', () => { + expect(songStore.getFormattedLength(factory('song', { length: 123 }))).toBe('02:03') + expect(songStore.getFormattedLength([ + factory('song', { length: 122 }), + factory('song', { length: 123 }) + ])).toBe('04:05') }) - it('guesses a song', () => { - throw 'Unimplemented' + it('gets songs by album', () => { + const songs = reactive(factory('song', 2, { album_id: 3 })) + songStore.vault.set(songs[0].id, songs[0]) + songStore.vault.set(songs[1].id, songs[1]) + const album = factory('album', { id: 3 }) + + expect(songStore.byAlbum(album)).toEqual(songs) + }) + + it('resolves a song', async () => { + const song = factory('song') + const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(song) + + expect(await songStore.resolve(song.id)).toEqual(song) + expect(getMock).toHaveBeenCalledWith(`songs/${song.id}`) + + // next call shouldn't make another request + expect(await songStore.resolve(song.id)).toEqual(song) + expect(getMock).toHaveBeenCalledOnce() + }) + + it('matches a song', () => { + const song = factory('song', { title: 'An amazing song' }) + const songs = [song, ...factory('song', 3)] + + expect(songStore.match('An amazing song', songs)).toEqual(song) + expect(songStore.match('An Amazing Song', songs)).toEqual(song) + }) + + it('registers a play', async () => { + const song = factory('song', { play_count: 42 }) + + const postMock = this.mock(httpService, 'post').mockResolvedValueOnce(factory('interaction', { + song_id: song.id, + play_count: 50 + })) + + await songStore.registerPlay(song) + expect(postMock).toHaveBeenCalledWith('interaction/play', { song: song.id }) + expect(song.play_count).toBe(50) + }) + + it('scrobbles', async () => { + const song = factory('song') + song.play_start_time = 123456789 + const postMock = this.mock(httpService, 'post') + + await songStore.scrobble(song) + + expect(postMock).toHaveBeenCalledWith(`songs/${song.id}/scrobble`, { timestamp: 123456789 }) + }) + + it('updates songs', async () => { + const songs = factory('song', 3) + + const result: SongUpdateResult = { + songs: factory('song', 3), + albums: factory('album', 2), + artists: factory('artist', 2), + removed: { + albums: [{ + id: 10, + artist_id: 3, + name: 'Removed Album', + cover: 'https://example.com/removed-album.jpg', + created_at: '2020-01-01' + }], + artists: [{ + id: 42, + name: 'Removed Artist', + image: 'https://example.com/removed-artist.jpg', + created_at: '2020-01-01' + }] + } + } + + const syncSongsMock = this.mock(songStore, 'syncWithVault') + const syncAlbumsMock = this.mock(albumStore, 'syncWithVault') + const syncArtistsMock = this.mock(artistStore, 'syncWithVault') + const removeAlbumsMock = this.mock(albumStore, 'removeByIds') + const removeArtistsMock = this.mock(artistStore, 'removeByIds') + const emitMock = this.mock(eventBus, 'emit') + const refreshMock = this.mock(overviewStore, 'refresh') + const putMock = this.mock(httpService, 'put').mockResolvedValueOnce(result) + + await songStore.update(songs, { + album_name: 'Updated Album', + artist_name: 'Updated Artist' + }) + + expect(putMock).toHaveBeenCalledWith('songs', { + data: { + album_name: 'Updated Album', + artist_name: 'Updated Artist' + }, + songs: songs.map(song => song.id) + }) + + expect(syncSongsMock).toHaveBeenCalledWith(result.songs) + expect(syncAlbumsMock).toHaveBeenCalledWith(result.albums) + expect(syncArtistsMock).toHaveBeenCalledWith(result.artists) + expect(removeAlbumsMock).toHaveBeenCalledWith([10]) + expect(removeArtistsMock).toHaveBeenCalledWith([42]) + expect(emitMock).toHaveBeenCalledWith('SONGS_UPDATED') + expect(refreshMock).toHaveBeenCalled() + }) + + it('gets source URL', () => { + commonStore.state.cdn_url = 'http://localhost/' + const song = factory('song', { id: 'foo' }) + this.mock(authService, 'getToken', 'hadouken') + + expect(songStore.getSourceUrl(song)).toBe('http://localhost/play/foo?api_token=hadouken') + + isMobile.any = true + preferenceStore.transcodeOnMobile = true + expect(songStore.getSourceUrl(song)).toBe('http://localhost/play/foo/1/128?api_token=hadouken') + }) + + it('gets shareable URL', () => { + const song = factory('song', { id: 'foo' }) + expect(songStore.getShareableUrl(song)).toBe('https://koel.test/#!/song/foo') + }) + + it('syncs with the vault', () => { + const song = factory('song', { + playback_state: null + }) + + const trackPlayCountMock = this.mock(songStore, 'trackPlayCount') + + expect(songStore.syncWithVault(song)).toEqual([reactive(song)]) + expect(songStore.vault.has(song.id)).toBe(true) + expect(trackPlayCountMock).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() + }) + + it('tracks play count', async () => { + const refreshMock = this.mock(overviewStore, 'refresh') + const artist = reactive(factory('artist', { id: 42, play_count: 100 })) + const album = reactive(factory('album', { id: 10, play_count: 120 })) + const albumArtist = reactive(factory('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', { + album_id: 10, + artist_id: 42, + album_artist_id: 43, + play_count: 98 + })) + + songStore.trackPlayCount(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() + }) + + it('fetches for album', async () => { + const songs = factory('song', 3) + const album = factory('album', { id: 42 }) + const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs) + const syncMock = this.mock(songStore, 'syncWithVault') + + await songStore.fetchForAlbum(album) + + expect(getMock).toHaveBeenCalledWith('albums/42/songs') + expect(syncMock).toHaveBeenCalledWith(songs) + }) + + it('fetches for artist', async () => { + const songs = factory('song', 3) + const artist = factory('artist', { id: 42 }) + const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs) + const syncMock = this.mock(songStore, 'syncWithVault') + + await songStore.fetchForArtist(artist) + + expect(getMock).toHaveBeenCalledWith('artists/42/songs') + expect(syncMock).toHaveBeenCalledWith(songs) + }) + + it('fetches for playlist', async () => { + const songs = factory('song', 3) + const playlist = factory('playlist', { id: 42 }) + const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs) + const syncMock = this.mock(songStore, 'syncWithVault') + + await songStore.fetchForPlaylist(playlist) + + expect(getMock).toHaveBeenCalledWith('playlists/42/songs') + expect(syncMock).toHaveBeenCalledWith(songs) + }) + + it('paginates', async () => { + const songs = factory('song', 3) + + const getMock = this.mock(httpService, 'get').mockResolvedValueOnce({ + data: songs, + links: { + next: 'http://localhost/api/v1/songs?page=3' + }, + meta: { + current_page: 2 + } + }) + + const syncMock = this.mock(songStore, 'syncWithVault', reactive(songs)) + + expect(await songStore.paginate('title', 'desc', 2)).toBe(3) + + expect(getMock).toHaveBeenCalledWith('songs?page=2&sort=title&order=desc') + expect(syncMock).toHaveBeenCalledWith(songs) + expect(songStore.state.songs).toEqual(reactive(songs)) }) } } diff --git a/resources/assets/js/stores/songStore.ts b/resources/assets/js/stores/songStore.ts index dd1bc727..f946e822 100644 --- a/resources/assets/js/stores/songStore.ts +++ b/resources/assets/js/stores/songStore.ts @@ -1,30 +1,22 @@ import isMobile from 'ismobilejs' import slugify from 'slugify' -import { orderBy, take, union, unionBy } from 'lodash' -import { reactive, watch } from 'vue' -import { arrayify, eventBus, secondsToHis, use } from '@/utils' +import { merge, orderBy, sumBy, take, unionBy } from 'lodash' +import { reactive, UnwrapNestedRefs, watch } from 'vue' +import { arrayify, eventBus, logger, secondsToHis, use } from '@/utils' import { authService, Cache, httpService } from '@/services' import { albumStore, artistStore, commonStore, overviewStore, preferenceStore } from '@/stores' -interface BroadcastSongData { - song: { - id: string - title: string - liked: boolean - playbackState: PlaybackState - album: { - id: number - name: string - cover: string - } - artist: { - id: number - name: string - } - } +export type SongUpdateData = { + title?: string + artist_name?: string + album_name?: string + album_artist_name?: string + track?: number | null + disc?: number | null + lyrics?: string } -interface SongUpdateResult { +export interface SongUpdateResult { songs: Song[] artists: Artist[] albums: Album[] @@ -35,27 +27,13 @@ interface SongUpdateResult { } export const songStore = { - vault: new Map(), + vault: new Map>(), state: reactive({ songs: [] as Song[] }), - /** - * Get the total duration of some songs. - * - * @param songs - * @param {Boolean} formatted Whether to convert the duration into H:i:s format - */ - getLength: (songs: Song[], formatted: boolean = false) => { - const duration = songs.reduce((length, song) => length + song.length, 0) - - return formatted ? secondsToHis(duration) : duration - }, - - getFormattedLength (songs: Song[]) { - return String(this.getLength(songs, true)) - }, + getFormattedLength: (songs: Song | Song[]) => secondsToHis(sumBy(arrayify(songs), 'length')), byId (id: string) { return this.vault.get(id) @@ -72,15 +50,17 @@ export const songStore = { }, async resolve (id: string) { - if (this.byId(id)) { - return this.byId(id) + let song = this.byId(id) + + if (!song) { + try { + song = this.syncWithVault(await httpService.get(`songs/${id}`))[0] + } catch (e) { + logger.error(e) + } } - try { - return this.syncWithVault(await httpService.get(`songs/${id}`))[0] - } catch (e) { - return null - } + return song }, /** @@ -109,9 +89,11 @@ export const songStore = { song.play_count = interaction.play_count }, - scrobble: async (song: Song) => await httpService.post(`${song.id}/scrobble`, { timestamp: song.play_start_time }), + scrobble: async (song: Song) => await httpService.post(`songs/${song.id}/scrobble`, { + timestamp: song.play_start_time + }), - async update (songsToUpdate: Song[], data: any) { + async update (songsToUpdate: Song[], data: SongUpdateData) { const { songs, artists, albums, removed } = await httpService.put('songs', { data, songs: songsToUpdate.map(song => song.id) @@ -138,42 +120,24 @@ export const songStore = { getShareableUrl: (song: Song) => `${window.BASE_URL}#!/song/${song.id}`, - generateDataToBroadcast: (song: Song): BroadcastSongData => ({ - song: { - id: song.id, - title: song.title, - liked: song.liked, - playbackState: song.playback_state || 'Stopped', - album: { - id: song.album_id, - name: song.album_name, - cover: song.album_cover - }, - artist: { - id: song.artist_id, - name: song.artist_name - } - } - }), - syncWithVault (songs: Song | Song[]) { return arrayify(songs).map(song => { let local = this.byId(song.id) if (local) { - Object.assign(local, song) + merge(local, song) } else { - song.playback_state = 'Stopped' local = reactive(song) - this.trackPlayCount(local!) + local.playback_state = 'Stopped' + this.trackPlayCount(local) + this.vault.set(local.id, local) } - this.vault.set(song.id, local) return local }) }, - trackPlayCount: (song: Song) => { + trackPlayCount: (song: UnwrapNestedRefs) => { watch(() => song.play_count, (newCount, oldCount) => { const album = albumStore.byId(song.album_id) album && (album.play_count += (newCount - oldCount)) @@ -211,7 +175,7 @@ export const songStore = { ) }, - async fetch (sortField: SongListSortField, sortOrder: SortOrder, page: number) { + async paginate (sortField: SongListSortField, sortOrder: SortOrder, page: number) { const resource = await httpService.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 f3e1959a..e45f7ad6 100644 --- a/resources/assets/js/types.d.ts +++ b/resources/assets/js/types.d.ts @@ -268,6 +268,7 @@ interface Settings { } interface Interaction { + type: 'interactions' readonly id: number readonly song_id: string liked: boolean diff --git a/routes/api.base.php b/routes/api.base.php index 21038fe8..efbfb83b 100644 --- a/routes/api.base.php +++ b/routes/api.base.php @@ -55,7 +55,11 @@ Route::prefix('api')->middleware('api')->group(static function (): void { Route::put('settings', [SettingController::class, 'update']); + /** + * @deprecated Use songs/{song}/scrobble instead + */ Route::post('{song}/scrobble', [ScrobbleController::class, 'store']); + Route::post('songs/{song}/scrobble', [ScrobbleController::class, 'store']); Route::put('songs', [SongController::class, 'update']); Route::post('upload', UploadController::class);