mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat(test): add songStore tests
This commit is contained in:
parent
5a6ddb226e
commit
9789933991
12 changed files with 343 additions and 107 deletions
|
@ -1,7 +1,8 @@
|
||||||
import factory from 'factoria'
|
import factory from 'factoria'
|
||||||
import artistFactory, { states as artistStates } from '@/__tests__/factory/artistFactory'
|
import artistFactory, { states as artistStates } from '@/__tests__/factory/artistFactory'
|
||||||
import songFactory, { states as songStates } from '@/__tests__/factory/songFactory'
|
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 smartPlaylistRuleFactory from '@/__tests__/factory/smartPlaylistRuleFactory'
|
||||||
import smartPlaylistRuleGroupFactory from '@/__tests__/factory/smartPlaylistRuleGroupFactory'
|
import smartPlaylistRuleGroupFactory from '@/__tests__/factory/smartPlaylistRuleGroupFactory'
|
||||||
import playlistFactory, { states as playlistStates } from '@/__tests__/factory/playlistFactory'
|
import playlistFactory, { states as playlistStates } from '@/__tests__/factory/playlistFactory'
|
||||||
|
@ -18,6 +19,7 @@ export default factory
|
||||||
.define('album-track', faker => albumTrackFactory(faker))
|
.define('album-track', faker => albumTrackFactory(faker))
|
||||||
.define('album-info', faker => albumInfoFactory(faker))
|
.define('album-info', faker => albumInfoFactory(faker))
|
||||||
.define('song', faker => songFactory(faker), songStates)
|
.define('song', faker => songFactory(faker), songStates)
|
||||||
|
.define('interaction', faker => interactionFactory(faker))
|
||||||
.define('video', faker => youTubeVideoFactory(faker))
|
.define('video', faker => youTubeVideoFactory(faker))
|
||||||
.define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker))
|
.define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker))
|
||||||
.define('smart-playlist-rule-group', faker => smartPlaylistRuleGroupFactory(faker))
|
.define('smart-playlist-rule-group', faker => smartPlaylistRuleGroupFactory(faker))
|
||||||
|
|
10
resources/assets/js/__tests__/factory/interactionFactory.ts
Normal file
10
resources/assets/js/__tests__/factory/interactionFactory.ts
Normal file
|
@ -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 })
|
||||||
|
})
|
|
@ -13,3 +13,5 @@ global.ResizeObserver = global.ResizeObserver ||
|
||||||
window.HTMLMediaElement.prototype.load = vi.fn()
|
window.HTMLMediaElement.prototype.load = vi.fn()
|
||||||
window.HTMLMediaElement.prototype.play = vi.fn()
|
window.HTMLMediaElement.prototype.play = vi.fn()
|
||||||
window.HTMLMediaElement.prototype.pause = vi.fn()
|
window.HTMLMediaElement.prototype.pause = vi.fn()
|
||||||
|
|
||||||
|
window.BASE_URL = 'https://koel.test/'
|
||||||
|
|
|
@ -13,7 +13,7 @@ new class extends UnitTestCase {
|
||||||
commonStore.state.song_count = 420
|
commonStore.state.song_count = 420
|
||||||
commonStore.state.song_length = 123_456
|
commonStore.state.song_length = 123_456
|
||||||
songStore.state.songs = factory<Song[]>('song', 20)
|
songStore.state.songs = factory<Song[]>('song', 20)
|
||||||
const fetchMock = this.mock(songStore, 'fetch').mockResolvedValue(2)
|
const fetchMock = this.mock(songStore, 'paginate').mockResolvedValue(2)
|
||||||
|
|
||||||
const rendered = this.render(AllSongsScreen, {
|
const rendered = this.render(AllSongsScreen, {
|
||||||
global: {
|
global: {
|
||||||
|
|
|
@ -84,7 +84,7 @@ const fetchSongs = async () => {
|
||||||
if (!moreSongsAvailable.value || loading) return
|
if (!moreSongsAvailable.value || loading) return
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
page.value = await songStore.fetch(sortField, sortOrder, page.value!)
|
page.value = await songStore.paginate(sortField, sortOrder, page.value!)
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -173,22 +173,12 @@
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
import { alerts, defaultCover, pluralize, requireInjection } from '@/utils'
|
import { alerts, defaultCover, pluralize, requireInjection } from '@/utils'
|
||||||
import { songStore } from '@/stores'
|
import { songStore, SongUpdateData } from '@/stores'
|
||||||
import { EditSongFormInitialTabKey, SongsKey } from '@/symbols'
|
import { EditSongFormInitialTabKey, SongsKey } from '@/symbols'
|
||||||
|
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
import SoundBar from '@/components/ui/SoundBar.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 [initialTab] = requireInjection(EditSongFormInitialTabKey)
|
||||||
const [songs] = requireInjection(SongsKey)
|
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.
|
* In order not to mess up the original songs, we manually assign and manipulate their attributes.
|
||||||
*/
|
*/
|
||||||
const formData = reactive<EditFormData>({
|
const formData = reactive<SongUpdateData>({
|
||||||
title: '',
|
title: '',
|
||||||
album_name: '',
|
album_name: '',
|
||||||
artist_name: '',
|
artist_name: '',
|
||||||
|
@ -223,7 +213,7 @@ const coverUrl = computed(() => allSongsAreInSameAlbum.value
|
||||||
: defaultCover
|
: defaultCover
|
||||||
)
|
)
|
||||||
|
|
||||||
const allSongsShareSameValue = (key: keyof EditFormData) => {
|
const allSongsShareSameValue = (key: keyof SongUpdateData) => {
|
||||||
if (editingOnlyOneSong.value) return true
|
if (editingOnlyOneSong.value) return true
|
||||||
return new Set(mutatedSongs.value.map(song => song[key])).size === 1
|
return new Set(mutatedSongs.value.map(song => song[key])).size === 1
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive, UnwrapNestedRefs } from 'vue'
|
||||||
import { differenceBy, merge, orderBy, take, unionBy } from 'lodash'
|
import { differenceBy, merge, orderBy, take, unionBy } from 'lodash'
|
||||||
import { Cache, httpService } from '@/services'
|
import { Cache, httpService } from '@/services'
|
||||||
import { arrayify } from '@/utils'
|
import { arrayify, logger } from '@/utils'
|
||||||
import { songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
|
|
||||||
const UNKNOWN_ALBUM_ID = 1
|
const UNKNOWN_ALBUM_ID = 1
|
||||||
|
|
||||||
export const albumStore = {
|
export const albumStore = {
|
||||||
vault: new Map<number, Album>(),
|
vault: new Map<number, UnwrapNestedRefs<Album>>(),
|
||||||
|
|
||||||
state: reactive({
|
state: reactive({
|
||||||
albums: []
|
albums: []
|
||||||
|
@ -64,8 +64,13 @@ export const albumStore = {
|
||||||
let album = this.byId(id)
|
let album = this.byId(id)
|
||||||
|
|
||||||
if (!album) {
|
if (!album) {
|
||||||
album = await Cache.resolve<Album>(['album', id], async () => await httpService.get<Album>(`albums/${id}`))
|
try {
|
||||||
this.syncWithVault(album)
|
album = this.syncWithVault(
|
||||||
|
await Cache.resolve<Album>(['album', id], async () => await httpService.get<Album>(`albums/${id}`))
|
||||||
|
)[0]
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return album
|
return album
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive, UnwrapNestedRefs } from 'vue'
|
||||||
import { differenceBy, orderBy, take, unionBy } from 'lodash'
|
import { differenceBy, orderBy, take, unionBy } from 'lodash'
|
||||||
import { Cache, httpService } from '@/services'
|
import { Cache, httpService } from '@/services'
|
||||||
import { arrayify } from '@/utils'
|
import { arrayify, logger } from '@/utils'
|
||||||
|
|
||||||
const UNKNOWN_ARTIST_ID = 1
|
const UNKNOWN_ARTIST_ID = 1
|
||||||
const VARIOUS_ARTISTS_ID = 2
|
const VARIOUS_ARTISTS_ID = 2
|
||||||
|
|
||||||
export const artistStore = {
|
export const artistStore = {
|
||||||
vault: new Map<number, Artist>(),
|
vault: new Map<number, UnwrapNestedRefs<Artist>>(),
|
||||||
|
|
||||||
state: reactive({
|
state: reactive({
|
||||||
artists: []
|
artists: []
|
||||||
|
@ -57,8 +57,13 @@ export const artistStore = {
|
||||||
let artist = this.byId(id)
|
let artist = this.byId(id)
|
||||||
|
|
||||||
if (!artist) {
|
if (!artist) {
|
||||||
artist = await Cache.resolve<Artist>(['artist', id], async () => await httpService.get<Artist>(`artists/${id}`))
|
try {
|
||||||
this.syncWithVault(artist)
|
artist = this.syncWithVault(
|
||||||
|
await Cache.resolve<Artist>(['artist', id], async () => await httpService.get<Artist>(`artists/${id}`))
|
||||||
|
)[0]
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return artist
|
return artist
|
||||||
|
|
|
@ -1,27 +1,280 @@
|
||||||
|
import isMobile from 'ismobilejs'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { expect, it } from 'vitest'
|
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 {
|
new class extends UnitTestCase {
|
||||||
|
protected afterEach () {
|
||||||
|
super.afterEach(() => {
|
||||||
|
isMobile.any = false
|
||||||
|
preferenceStore.transcodeOnMobile = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
protected test () {
|
protected test () {
|
||||||
it('gets a song by ID', () => {
|
it('gets a song by ID', () => {
|
||||||
expect(songStore.byId('e6d3977f3ffa147801ca5d1fdf6fa55e')!.title).toBe('Like a rolling stone')
|
const song = reactive(factory<Song>('song', { id: 'foo' }))
|
||||||
|
songStore.vault.set('foo', reactive(song))
|
||||||
|
songStore.vault.set('bar', reactive(factory<Song>('song', { id: 'bar' })))
|
||||||
|
|
||||||
|
expect(songStore.byId('foo')).toBe(song)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gets multiple songs by IDs', () => {
|
it('gets songs by IDs', () => {
|
||||||
const songs = songStore.byIds(['e6d3977f3ffa147801ca5d1fdf6fa55e', 'aa16bbef6a9710eb9a0f41ecc534fad5'])
|
const foo = reactive(factory<Song>('song', { id: 'foo' }))
|
||||||
expect(songs[0].title).toBe('Like a rolling stone')
|
const bar = reactive(factory<Song>('song', { id: 'bar' }))
|
||||||
expect(songs[1].title).toBe('Knockin\' on heaven\'s door')
|
songStore.vault.set('foo', foo)
|
||||||
|
songStore.vault.set('bar', bar)
|
||||||
|
songStore.vault.set('baz', reactive(factory<Song>('song', { id: 'baz' })))
|
||||||
|
|
||||||
|
expect(songStore.byIds(['foo', 'bar'])).toEqual([foo, bar])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets interaction status', () => {
|
it('gets formatted length', () => {
|
||||||
const song = songStore.byId('cb7edeac1f097143e65b1b2cde102482')!
|
expect(songStore.getFormattedLength(factory<Song>('song', { length: 123 }))).toBe('02:03')
|
||||||
expect(song.liked).toBe(true)
|
expect(songStore.getFormattedLength([
|
||||||
expect(song.play_count).toBe(3)
|
factory<Song>('song', { length: 122 }),
|
||||||
|
factory<Song>('song', { length: 123 })
|
||||||
|
])).toBe('04:05')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('guesses a song', () => {
|
it('gets songs by album', () => {
|
||||||
throw 'Unimplemented'
|
const songs = reactive(factory<Song>('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>('album', { id: 3 })
|
||||||
|
|
||||||
|
expect(songStore.byAlbum(album)).toEqual(songs)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves a song', async () => {
|
||||||
|
const song = factory<Song>('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>('song', { title: 'An amazing song' })
|
||||||
|
const songs = [song, ...factory<Song[]>('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>('song', { play_count: 42 })
|
||||||
|
|
||||||
|
const postMock = this.mock(httpService, 'post').mockResolvedValueOnce(factory<Interaction>('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')
|
||||||
|
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[]>('song', 3)
|
||||||
|
|
||||||
|
const result: SongUpdateResult = {
|
||||||
|
songs: factory<Song[]>('song', 3),
|
||||||
|
albums: factory<Album[]>('album', 2),
|
||||||
|
artists: factory<Artist[]>('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>('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>('song', { id: 'foo' })
|
||||||
|
expect(songStore.getShareableUrl(song)).toBe('https://koel.test/#!/song/foo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs with the vault', () => {
|
||||||
|
const song = factory<Song>('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>('artist', { id: 42, play_count: 100 }))
|
||||||
|
const album = reactive(factory<Album>('album', { id: 10, play_count: 120 }))
|
||||||
|
const albumArtist = reactive(factory<Artist>('artist', { id: 43, play_count: 130 }))
|
||||||
|
|
||||||
|
artistStore.vault.set(42, artist)
|
||||||
|
artistStore.vault.set(43, albumArtist)
|
||||||
|
albumStore.vault.set(10, album)
|
||||||
|
|
||||||
|
const song = reactive(factory<Song>('song', {
|
||||||
|
album_id: 10,
|
||||||
|
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[]>('song', 3)
|
||||||
|
const album = factory<Album>('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[]>('song', 3)
|
||||||
|
const artist = factory<Artist>('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[]>('song', 3)
|
||||||
|
const playlist = factory<Playlist>('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[]>('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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,30 +1,22 @@
|
||||||
import isMobile from 'ismobilejs'
|
import isMobile from 'ismobilejs'
|
||||||
import slugify from 'slugify'
|
import slugify from 'slugify'
|
||||||
import { orderBy, take, union, unionBy } from 'lodash'
|
import { merge, orderBy, sumBy, take, unionBy } from 'lodash'
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, UnwrapNestedRefs, watch } from 'vue'
|
||||||
import { arrayify, eventBus, secondsToHis, use } from '@/utils'
|
import { arrayify, eventBus, logger, secondsToHis, use } from '@/utils'
|
||||||
import { authService, Cache, httpService } from '@/services'
|
import { authService, Cache, httpService } from '@/services'
|
||||||
import { albumStore, artistStore, commonStore, overviewStore, preferenceStore } from '@/stores'
|
import { albumStore, artistStore, commonStore, overviewStore, preferenceStore } from '@/stores'
|
||||||
|
|
||||||
interface BroadcastSongData {
|
export type SongUpdateData = {
|
||||||
song: {
|
title?: string
|
||||||
id: string
|
artist_name?: string
|
||||||
title: string
|
album_name?: string
|
||||||
liked: boolean
|
album_artist_name?: string
|
||||||
playbackState: PlaybackState
|
track?: number | null
|
||||||
album: {
|
disc?: number | null
|
||||||
id: number
|
lyrics?: string
|
||||||
name: string
|
|
||||||
cover: string
|
|
||||||
}
|
|
||||||
artist: {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SongUpdateResult {
|
export interface SongUpdateResult {
|
||||||
songs: Song[]
|
songs: Song[]
|
||||||
artists: Artist[]
|
artists: Artist[]
|
||||||
albums: Album[]
|
albums: Album[]
|
||||||
|
@ -35,27 +27,13 @@ interface SongUpdateResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const songStore = {
|
export const songStore = {
|
||||||
vault: new Map<string, Song>(),
|
vault: new Map<string, UnwrapNestedRefs<Song>>(),
|
||||||
|
|
||||||
state: reactive({
|
state: reactive({
|
||||||
songs: [] as Song[]
|
songs: [] as Song[]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
getFormattedLength: (songs: Song | Song[]) => secondsToHis(sumBy(arrayify(songs), 'length')),
|
||||||
* 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))
|
|
||||||
},
|
|
||||||
|
|
||||||
byId (id: string) {
|
byId (id: string) {
|
||||||
return this.vault.get(id)
|
return this.vault.get(id)
|
||||||
|
@ -72,15 +50,17 @@ export const songStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolve (id: string) {
|
async resolve (id: string) {
|
||||||
if (this.byId(id)) {
|
let song = this.byId(id)
|
||||||
return this.byId(id)
|
|
||||||
|
if (!song) {
|
||||||
|
try {
|
||||||
|
song = this.syncWithVault(await httpService.get<Song>(`songs/${id}`))[0]
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return song
|
||||||
return this.syncWithVault(await httpService.get<Song>(`songs/${id}`))[0]
|
|
||||||
} catch (e) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -109,9 +89,11 @@ export const songStore = {
|
||||||
song.play_count = interaction.play_count
|
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<SongUpdateResult>('songs', {
|
const { songs, artists, albums, removed } = await httpService.put<SongUpdateResult>('songs', {
|
||||||
data,
|
data,
|
||||||
songs: songsToUpdate.map(song => song.id)
|
songs: songsToUpdate.map(song => song.id)
|
||||||
|
@ -138,42 +120,24 @@ export const songStore = {
|
||||||
|
|
||||||
getShareableUrl: (song: Song) => `${window.BASE_URL}#!/song/${song.id}`,
|
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[]) {
|
syncWithVault (songs: Song | Song[]) {
|
||||||
return arrayify(songs).map(song => {
|
return arrayify(songs).map(song => {
|
||||||
let local = this.byId(song.id)
|
let local = this.byId(song.id)
|
||||||
|
|
||||||
if (local) {
|
if (local) {
|
||||||
Object.assign(local, song)
|
merge(local, song)
|
||||||
} else {
|
} else {
|
||||||
song.playback_state = 'Stopped'
|
|
||||||
local = reactive(song)
|
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
|
return local
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
trackPlayCount: (song: Song) => {
|
trackPlayCount: (song: UnwrapNestedRefs<Song>) => {
|
||||||
watch(() => song.play_count, (newCount, oldCount) => {
|
watch(() => song.play_count, (newCount, oldCount) => {
|
||||||
const album = albumStore.byId(song.album_id)
|
const album = albumStore.byId(song.album_id)
|
||||||
album && (album.play_count += (newCount - oldCount))
|
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<PaginatorResource>(
|
const resource = await httpService.get<PaginatorResource>(
|
||||||
`songs?page=${page}&sort=${sortField}&order=${sortOrder}`
|
`songs?page=${page}&sort=${sortField}&order=${sortOrder}`
|
||||||
)
|
)
|
||||||
|
|
1
resources/assets/js/types.d.ts
vendored
1
resources/assets/js/types.d.ts
vendored
|
@ -268,6 +268,7 @@ interface Settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Interaction {
|
interface Interaction {
|
||||||
|
type: 'interactions'
|
||||||
readonly id: number
|
readonly id: number
|
||||||
readonly song_id: string
|
readonly song_id: string
|
||||||
liked: boolean
|
liked: boolean
|
||||||
|
|
|
@ -55,7 +55,11 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
||||||
|
|
||||||
Route::put('settings', [SettingController::class, 'update']);
|
Route::put('settings', [SettingController::class, 'update']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use songs/{song}/scrobble instead
|
||||||
|
*/
|
||||||
Route::post('{song}/scrobble', [ScrobbleController::class, 'store']);
|
Route::post('{song}/scrobble', [ScrobbleController::class, 'store']);
|
||||||
|
Route::post('songs/{song}/scrobble', [ScrobbleController::class, 'store']);
|
||||||
Route::put('songs', [SongController::class, 'update']);
|
Route::put('songs', [SongController::class, 'update']);
|
||||||
|
|
||||||
Route::post('upload', UploadController::class);
|
Route::post('upload', UploadController::class);
|
||||||
|
|
Loading…
Reference in a new issue