feat(ui): add song Love and Rating functionality to playlist view (#4134)

* feat(ui): add playlist track love button

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add star rating feature for playlist tracks

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): handle loading state and error logging in toggle love and rating components

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-06-04 20:38:28 -04:00
committed by GitHub
parent ee8ef661c3
commit 4172d2332a
9 changed files with 409 additions and 26 deletions
+4 -3
View File
@@ -38,15 +38,16 @@ export const RatingField = ({
const handleRating = useCallback(
(e, val) => {
rate(val ?? 0, e.target.name)
const targetId = record.mediaFileId || record.id
rate(val ?? 0, targetId)
},
[rate],
[rate, record.mediaFileId, record.id],
)
return (
<span onClick={(e) => stopPropagation(e)}>
<Rating
name={record.id}
name={record.mediaFileId || record.id}
className={clsx(
className,
classes.rating,
+36 -12
View File
@@ -17,18 +17,42 @@ export const useRating = (resource, record) => {
}, [])
const refreshRating = useCallback(() => {
dataProvider
.getOne(resource, { id: record.id })
.then(() => {
if (mountedRef.current) {
setLoading(false)
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error encountered: ' + e)
})
}, [dataProvider, record, resource])
// For playlist tracks, refresh both resources to keep data in sync
if (record.mediaFileId) {
// This is a playlist track - refresh both the playlist track and the song
const promises = [
dataProvider.getOne('song', { id: record.mediaFileId }),
dataProvider.getOne('playlistTrack', {
id: record.id,
filter: { playlist_id: record.playlistId },
}),
]
Promise.all(promises)
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error encountered: ' + e)
})
.finally(() => {
if (mountedRef.current) {
setLoading(false)
}
})
} else {
// Regular song or other resource
dataProvider
.getOne(resource, { id: record.id })
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error encountered: ' + e)
})
.finally(() => {
if (mountedRef.current) {
setLoading(false)
}
})
}
}, [dataProvider, record.id, record.mediaFileId, record.playlistId, resource])
const rate = (val, id) => {
setLoading(true)
+165
View File
@@ -0,0 +1,165 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { useRating } from './useRating'
import subsonic from '../subsonic'
import { useDataProvider } from 'react-admin'
vi.mock('../subsonic', () => ({
default: {
setRating: vi.fn(() => Promise.resolve()),
},
}))
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
useDataProvider: vi.fn(),
useNotify: vi.fn(() => vi.fn()),
}
})
describe('useRating', () => {
let getOne
beforeEach(() => {
getOne = vi.fn(() => Promise.resolve())
useDataProvider.mockReturnValue({ getOne })
vi.clearAllMocks()
})
it('returns rating value from record', () => {
const record = { id: 'sg-1', rating: 3 }
const { result } = renderHook(() => useRating('song', record))
const [rate, rating, loading] = result.current
expect(rating).toBe(3)
expect(loading).toBe(false)
expect(typeof rate).toBe('function')
})
it('sets rating using targetId and calls setRating API', async () => {
const record = { id: 'sg-1', rating: 0 }
const { result } = renderHook(() => useRating('song', record))
await act(async () => {
await result.current[0](4, 'sg-1')
})
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4)
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('handles zero rating (unrate)', async () => {
const record = { id: 'sg-1', rating: 5 }
const { result } = renderHook(() => useRating('song', record))
await act(async () => {
await result.current[0](0, 'sg-1')
})
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 0)
})
describe('playlist track scenarios', () => {
it('refreshes both playlist track and song for playlist tracks', async () => {
const record = {
id: 'pt-1',
mediaFileId: 'sg-1',
playlistId: 'pl-1',
rating: 2,
}
const { result } = renderHook(() => useRating('playlistTrack', record))
await act(async () => {
await result.current[0](5, 'sg-1')
})
// Should rate using the media file ID
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5)
// Should refresh both the playlist track and the song
expect(getOne).toHaveBeenCalledTimes(2)
expect(getOne).toHaveBeenCalledWith('playlistTrack', {
id: 'pt-1',
filter: { playlist_id: 'pl-1' },
})
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('includes playlist_id filter when refreshing playlist tracks', async () => {
const record = {
id: 'pt-5',
mediaFileId: 'sg-10',
playlistId: 'pl-123',
rating: 1,
}
const { result } = renderHook(() => useRating('playlistTrack', record))
await act(async () => {
await result.current[0](3, 'sg-10')
})
// Should rate using the media file ID
expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3)
// Should refresh playlist track with correct playlist_id filter
expect(getOne).toHaveBeenCalledWith('playlistTrack', {
id: 'pt-5',
filter: { playlist_id: 'pl-123' },
})
// Should also refresh the underlying song
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' })
})
it('only refreshes original resource when no mediaFileId present', async () => {
const record = { id: 'sg-1', rating: 4 }
const { result } = renderHook(() => useRating('song', record))
await act(async () => {
await result.current[0](2, 'sg-1')
})
// Should only refresh the original resource (song)
expect(getOne).toHaveBeenCalledTimes(1)
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('does not include playlist_id filter for non-playlist resources', async () => {
const record = { id: 'sg-1', rating: 0 }
const { result } = renderHook(() => useRating('song', record))
await act(async () => {
await result.current[0](5, 'sg-1')
})
// Should refresh without any filter
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
})
describe('component integration scenarios', () => {
it('handles mediaFileId fallback correctly for playlist tracks', async () => {
const record = {
id: 'pt-1',
mediaFileId: 'sg-1',
playlistId: 'pl-1',
rating: 0,
}
const { result } = renderHook(() => useRating('playlistTrack', record))
// Simulate RatingField component behavior: uses mediaFileId || record.id
const targetId = record.mediaFileId || record.id
await act(async () => {
await result.current[0](4, targetId)
})
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4)
})
it('handles regular song rating without mediaFileId', async () => {
const record = { id: 'sg-1', rating: 2 }
const { result } = renderHook(() => useRating('song', record))
// Simulate RatingField component behavior: uses mediaFileId || record.id
const targetId = record.mediaFileId || record.id
await act(async () => {
await result.current[0](5, targetId)
})
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5)
expect(getOne).toHaveBeenCalledTimes(1)
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
})
})
+27 -7
View File
@@ -17,18 +17,38 @@ export const useToggleLove = (resource, record = {}) => {
const dataProvider = useDataProvider()
const refreshRecord = useCallback(() => {
dataProvider.getOne(resource, { id: record.id }).then(() => {
if (mountedRef.current) {
setLoading(false)
}
})
}, [dataProvider, record.id, resource])
const promises = []
// Always refresh the original resource
const params = { id: record.id }
if (record.playlistId) {
params.filter = { playlist_id: record.playlistId }
}
promises.push(dataProvider.getOne(resource, params))
// If we have a mediaFileId, also refresh the song
if (record.mediaFileId) {
promises.push(dataProvider.getOne('song', { id: record.mediaFileId }))
}
Promise.all(promises)
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error encountered: ' + e)
})
.finally(() => {
if (mountedRef.current) {
setLoading(false)
}
})
}, [dataProvider, record.mediaFileId, record.id, record.playlistId, resource])
const toggleLove = () => {
const toggle = record.starred ? subsonic.unstar : subsonic.star
const id = record.mediaFileId || record.id
setLoading(true)
toggle(record.id)
toggle(id)
.then(refreshRecord)
.catch((e) => {
// eslint-disable-next-line no-console
+136
View File
@@ -0,0 +1,136 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { useToggleLove } from './useToggleLove'
import subsonic from '../subsonic'
import { useDataProvider } from 'react-admin'
vi.mock('../subsonic', () => ({
default: {
star: vi.fn(() => Promise.resolve()),
unstar: vi.fn(() => Promise.resolve()),
},
}))
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
useDataProvider: vi.fn(),
useNotify: vi.fn(() => vi.fn()),
}
})
describe('useToggleLove', () => {
let getOne
beforeEach(() => {
getOne = vi.fn(() => Promise.resolve())
useDataProvider.mockReturnValue({ getOne })
vi.clearAllMocks()
})
it('uses mediaFileId when present', async () => {
const record = { id: 'pt-1', mediaFileId: 'sg-1', starred: false }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('falls back to id when mediaFileId not present', async () => {
const record = { id: 'sg-1', starred: false }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('calls unstar when record is already loved', async () => {
const record = { id: 'sg-1', starred: true }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
expect(subsonic.unstar).toHaveBeenCalledWith('sg-1')
})
describe('playlist track scenarios', () => {
it('refreshes both playlist track and song for playlist tracks', async () => {
const record = {
id: 'pt-1',
mediaFileId: 'sg-1',
playlistId: 'pl-1',
starred: false,
}
const { result } = renderHook(() =>
useToggleLove('playlistTrack', record),
)
await act(async () => {
await result.current[0]()
})
// Should star using the media file ID
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
// Should refresh both the playlist track and the song
expect(getOne).toHaveBeenCalledTimes(2)
expect(getOne).toHaveBeenCalledWith('playlistTrack', {
id: 'pt-1',
filter: { playlist_id: 'pl-1' },
})
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('includes playlist_id filter when refreshing playlist tracks', async () => {
const record = {
id: 'pt-5',
mediaFileId: 'sg-10',
playlistId: 'pl-123',
starred: true,
}
const { result } = renderHook(() =>
useToggleLove('playlistTrack', record),
)
await act(async () => {
await result.current[0]()
})
// Should unstar using the media file ID
expect(subsonic.unstar).toHaveBeenCalledWith('sg-10')
// Should refresh playlist track with correct playlist_id filter
expect(getOne).toHaveBeenCalledWith('playlistTrack', {
id: 'pt-5',
filter: { playlist_id: 'pl-123' },
})
// Should also refresh the underlying song
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' })
})
it('only refreshes original resource when no mediaFileId present', async () => {
const record = { id: 'sg-1', starred: false }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
// Should only refresh the original resource (song)
expect(getOne).toHaveBeenCalledTimes(1)
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('does not include playlist_id filter for non-playlist resources', async () => {
const record = { id: 'sg-1', starred: false }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
// Should refresh without any filter
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
})
})