From ded8cf236e6ad55c48f4e5ab2bdf204b815c9636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 30 May 2025 21:26:35 -0400 Subject: [PATCH] feat(ui): add 'Show in Playlist' context menu (#4139) * Update song playlist menu and endpoint * feat(ui): show submenu on click, not on hover Signed-off-by: Deluan * feat(ui): integrate dataProvider for fetching playlists in song context menu Signed-off-by: Deluan * feat(ui): update song context menu to use dataProvider for fetching playlists and inspecting songs Signed-off-by: Deluan * feat(ui): stop event propagation when closing playlist submenu Signed-off-by: Deluan * feat(ui): add 'show in playlist' option to options object Signed-off-by: Deluan --------- Signed-off-by: Deluan --- model/playlist.go | 1 + persistence/playlist_repository.go | 19 ++++ persistence/playlist_repository_test.go | 15 +++ server/nativeapi/native_api.go | 10 ++ server/nativeapi/playlists.go | 18 ++++ ui/src/common/SongContextMenu.jsx | 101 +++++++++++++++++++-- ui/src/common/SongContextMenu.test.jsx | 82 +++++++++++++++++ ui/src/dataProvider/wrapperDataProvider.js | 10 ++ ui/src/i18n/en.json | 1 + 9 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 ui/src/common/SongContextMenu.test.jsx diff --git a/model/playlist.go b/model/playlist.go index 96a431b4..40b666ff 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -101,6 +101,7 @@ type PlaylistRepository interface { FindByPath(path string) (*Playlist, error) Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository + GetPlaylists(mediaFileId string) (Playlists, error) } type PlaylistTrack struct { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 743eca47..bdaaeedd 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -197,6 +197,25 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } +func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { + sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}). + Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id"). + Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()}) + var res []dbPlaylist + err := r.queryAll(sel, &res) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.Playlists{}, nil + } + return nil, err + } + playlists := make(model.Playlists, len(res)) + for i, p := range res { + playlists[i] = p.Playlist + } + return playlists, nil +} + func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { return r.newSelect(options...).Join("user on user.id = owner_id"). Columns(r.tableName+".*", "user.user_name as owner_name") diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 5a82964c..b799d491 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -112,6 +112,21 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetPlaylists", func() { + It("returns playlists for a track", func() { + pls, err := repo.GetPlaylists(songRadioactivity.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(pls).To(HaveLen(1)) + Expect(pls[0].ID).To(Equal(plsBest.ID)) + }) + + It("returns empty when none", func() { + pls, err := repo.GetPlaylists("9999") + Expect(err).ToNot(HaveOccurred()) + Expect(pls).To(HaveLen(0)) + }) + }) + Context("Smart Playlists", func() { var rules *criteria.Criteria BeforeEach(func() { diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index f2c13fa3..4dfc5961 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -59,6 +59,7 @@ func (n *Router) routes() http.Handler { n.addPlaylistRoute(r) n.addPlaylistTrackRoute(r) + n.addSongPlaylistsRoute(r) n.addMissingFilesRoute(r) n.addInspectRoute(r) n.addConfigRoute(r) @@ -142,6 +143,15 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) { }) } +func (n *Router) addSongPlaylistsRoute(r chi.Router) { + r.Route("/song/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/playlists", func(w http.ResponseWriter, r *http.Request) { + getSongPlaylists(n.ds)(w, r) + }) + }) +} + func (n *Router) addMissingFilesRoute(r chi.Router) { r.Route("/missing", func(r chi.Router) { n.RX(r, "/", newMissingRepository(n.ds), false) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 1e8e961c..9feac718 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -207,3 +207,21 @@ func reorderItem(ds model.DataStore) http.HandlerFunc { } } } + +func getSongPlaylists(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + p := req.Params(r) + trackId, _ := p.String(":id") + playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + data, err := json.Marshal(playlists) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = w.Write(data) + } +} diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx index f2227dc7..32fbeb24 100644 --- a/ui/src/common/SongContextMenu.jsx +++ b/ui/src/common/SongContextMenu.jsx @@ -1,7 +1,12 @@ import React, { useState } from 'react' import PropTypes from 'prop-types' import { useDispatch } from 'react-redux' -import { useNotify, usePermissions, useTranslate } from 'react-admin' +import { + useNotify, + usePermissions, + useTranslate, + useDataProvider, +} from 'react-admin' import { IconButton, Menu, MenuItem } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import MoreVertIcon from '@material-ui/icons/MoreVert' @@ -20,7 +25,7 @@ import { import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' -import { httpClient } from '../dataProvider' +import { useRedirect } from 'react-admin' const useStyles = makeStyles({ noWrap: { @@ -57,8 +62,13 @@ export const SongContextMenu = ({ const dispatch = useDispatch() const translate = useTranslate() const notify = useNotify() + const dataProvider = useDataProvider() const [anchorEl, setAnchorEl] = useState(null) + const [playlistAnchorEl, setPlaylistAnchorEl] = useState(null) + const [playlists, setPlaylists] = useState([]) + const [playlistsLoaded, setPlaylistsLoaded] = useState(false) const { permissions } = usePermissions() + const redirect = useRedirect() const options = { playNow: { @@ -87,6 +97,15 @@ export const SongContextMenu = ({ }), ), }, + showInPlaylist: { + enabled: true, + label: + translate('resources.song.actions.showInPlaylist') + + (playlists.length > 0 ? ' ►' : ''), + action: (record, e) => { + setPlaylistAnchorEl(e.currentTarget) + }, + }, share: { enabled: config.enableSharing, label: translate('ra.action.share'), @@ -113,8 +132,8 @@ export const SongContextMenu = ({ if (permissions === 'admin' && !record.missing) { try { let id = record.mediaFileId ?? record.id - const data = await httpClient(`/api/inspect?id=${id}`) - fullRecord = { ...record, rawTags: data.json.rawTags } + const data = await dataProvider.inspect(id) + fullRecord = { ...record, rawTags: data.data.rawTags } } catch (error) { notify( translate('ra.notification.http_error') + ': ' + error.message, @@ -134,6 +153,21 @@ export const SongContextMenu = ({ const handleClick = (e) => { setAnchorEl(e.currentTarget) + if (!playlistsLoaded) { + const id = record.mediaFileId || record.id + dataProvider + .getPlaylists(id) + .then((res) => { + setPlaylists(res.data) + setPlaylistsLoaded(true) + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to fetch playlists:', error) + setPlaylists([]) + setPlaylistsLoaded(true) + }) + } e.stopPropagation() } @@ -144,12 +178,39 @@ export const SongContextMenu = ({ const handleItemClick = (e) => { e.preventDefault() - setAnchorEl(null) const key = e.target.getAttribute('value') - options[key].action(record) + const action = options[key].action + + if (key === 'showInPlaylist') { + // For showInPlaylist, we keep the main menu open and show submenu + action(record, e) + } else { + // For other actions, close the main menu + setAnchorEl(null) + action(record) + } e.stopPropagation() } + const handlePlaylistClose = (e) => { + setPlaylistAnchorEl(null) + if (e) { + e.stopPropagation() + } + } + + const handleMainMenuClose = (e) => { + setAnchorEl(null) + setPlaylistAnchorEl(null) // Close both menus + e.stopPropagation() + } + + const handlePlaylistClick = (id, e) => { + e.stopPropagation() + redirect(`/playlist/${id}/show`) + handlePlaylistClose() + } + const open = Boolean(anchorEl) if (!record) { @@ -170,17 +231,41 @@ export const SongContextMenu = ({ id={'menu' + record.id} anchorEl={anchorEl} open={open} - onClose={handleClose} + onClose={handleMainMenuClose} > {Object.keys(options).map( (key) => options[key].enabled && ( - + {options[key].label} ), )} + + {playlists.map((p) => ( + handlePlaylistClick(p.id, e)}> + {p.name} + + ))} + ) } diff --git a/ui/src/common/SongContextMenu.test.jsx b/ui/src/common/SongContextMenu.test.jsx new file mode 100644 index 00000000..ee6a358d --- /dev/null +++ b/ui/src/common/SongContextMenu.test.jsx @@ -0,0 +1,82 @@ +import React from 'react' +import { render, fireEvent, screen, waitFor } from '@testing-library/react' +import { TestContext } from 'ra-test' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SongContextMenu } from './SongContextMenu' + +vi.mock('../dataProvider', () => ({ + httpClient: vi.fn(), +})) + +vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() })) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRedirect: () => (url) => { + window.location.hash = `#${url}` + }, + useDataProvider: () => ({ + getPlaylists: vi.fn().mockResolvedValue({ + data: [{ id: 'pl1', name: 'Pl 1' }], + }), + inspect: vi.fn().mockResolvedValue({ + data: { rawTags: {} }, + }), + }), + } +}) + +describe('SongContextMenu', () => { + beforeEach(() => { + vi.clearAllMocks() + window.location.hash = '' + }) + + it('navigates to playlist when selected', async () => { + render( + + + , + ) + fireEvent.click(screen.getAllByRole('button')[1]) + await waitFor(() => + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + fireEvent.click( + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + await waitFor(() => screen.getByText('Pl 1')) + fireEvent.click(screen.getByText('Pl 1')) + expect(window.location.hash).toBe('#/playlist/pl1/show') + }) + + it('stops event propagation when playlist submenu is closed', async () => { + const mockOnClick = vi.fn() + render( + +
+ +
+
, + ) + + // Open main menu + fireEvent.click(screen.getAllByRole('button')[1]) + await waitFor(() => + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + + // Open playlist submenu + fireEvent.click( + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + await waitFor(() => screen.getByText('Pl 1')) + + // Click outside the playlist submenu (should close it without triggering parent click) + fireEvent.click(document.body) + + expect(mockOnClick).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index bf487dc7..257a274e 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -90,6 +90,16 @@ const wrapperDataProvider = { body: JSON.stringify(data), }).then(({ json }) => ({ data: json })) }, + getPlaylists: (songId) => { + return httpClient(`${REST_URL}/song/${songId}/playlists`).then( + ({ json }) => ({ data: json }), + ) + }, + inspect: (songId) => { + return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({ + data: json, + })) + }, } export default wrapperDataProvider diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 83d8c02f..a8b026dd 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -41,6 +41,7 @@ "addToQueue": "Play Later", "playNow": "Play Now", "addToPlaylist": "Add to Playlist", + "showInPlaylist": "Show in Playlist", "shuffleAll": "Shuffle All", "download": "Download", "playNext": "Play Next",