From 1b11bdfdc6a537d7b033f02f10e88123e8902d59 Mon Sep 17 00:00:00 2001 From: Jacob Shapiro Date: Fri, 10 Apr 2026 23:48:23 +0200 Subject: [PATCH] Add admin-only in-place tag editing --- cmd/wire_gen.go | 2 +- core/tageditor/service.go | 368 ++++++++++++++++++++++ server/nativeapi/native_api.go | 6 +- server/nativeapi/tag_editor.go | 73 +++++ ui/src/album/AlbumActions.jsx | 2 + ui/src/album/AlbumSongs.jsx | 8 + ui/src/common/SongContextMenu.jsx | 32 +- ui/src/common/SongSimpleList.jsx | 21 +- ui/src/playlist/PlaylistSongs.jsx | 8 + ui/src/song/SongList.jsx | 10 +- ui/src/tageditor/TagEditorAlbumButton.jsx | 59 ++++ ui/src/tageditor/TagEditorDialog.jsx | 167 ++++++++++ ui/src/tageditor/TagEditorSongButton.jsx | 62 ++++ ui/src/tageditor/TagEditorSongDialog.jsx | 60 ++++ ui/src/tageditor/index.js | 3 + 15 files changed, 870 insertions(+), 11 deletions(-) create mode 100644 core/tageditor/service.go create mode 100644 server/nativeapi/tag_editor.go create mode 100644 ui/src/tageditor/TagEditorAlbumButton.jsx create mode 100644 ui/src/tageditor/TagEditorDialog.jsx create mode 100644 ui/src/tageditor/TagEditorSongButton.jsx create mode 100644 ui/src/tageditor/TagEditorSongDialog.jsx create mode 100644 ui/src/tageditor/index.js diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 5b9fd648f..fcc70eefa 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -80,7 +80,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, modelScanner, manager, imageUploadService) return router } diff --git a/core/tageditor/service.go b/core/tageditor/service.go new file mode 100644 index 000000000..a6cd9a72e --- /dev/null +++ b/core/tageditor/service.go @@ -0,0 +1,368 @@ +package tageditor + +import ( + "context" + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "go.senan.xyz/taglib" +) + +type Service struct { + ds model.DataStore + scanner model.Scanner +} + +func New(ds model.DataStore, scanner model.Scanner) *Service { + return &Service{ds: ds, scanner: scanner} +} + +type SongPayload struct { + ID string `json:"id"` + Path string `json:"path,omitempty"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + AlbumArtist string `json:"albumArtist"` + TrackNumber string `json:"trackNumber"` + DiscNumber string `json:"discNumber"` + Date string `json:"date"` + ReleaseDate string `json:"releaseDate"` + OriginalDate string `json:"originalDate"` + Genre string `json:"genre"` + Comment string `json:"comment"` + Lyrics string `json:"lyrics"` +} + +type AlbumPayload struct { + ID string `json:"id"` + Name string `json:"name"` + AlbumArtist string `json:"albumArtist"` + Date string `json:"date"` + ReleaseDate string `json:"releaseDate"` + OriginalDate string `json:"originalDate"` + Genre string `json:"genre"` + Comment string `json:"comment"` + Compilation bool `json:"compilation"` + SongCount int `json:"songCount,omitempty"` +} + +func (s *Service) GetSong(ctx context.Context, id string) (*SongPayload, error) { + mf, err := s.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, fmt.Errorf("get song %s: %w", id, err) + } + return songToPayload(*mf), nil +} + +func (s *Service) UpdateSong(ctx context.Context, id string, payload SongPayload) (*SongPayload, error) { + mf, err := s.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, fmt.Errorf("get song %s: %w", id, err) + } + if mf.Missing { + return nil, fmt.Errorf("cannot edit tags for missing file: %s", mf.Path) + } + + tags, err := taglib.ReadTags(mf.AbsolutePath()) + if err != nil { + return nil, fmt.Errorf("read tags from %s: %w", mf.Path, err) + } + + applySongPayload(tags, payload) + + if err := taglib.WriteTags(mf.AbsolutePath(), tags, taglib.Clear); err != nil { + return nil, fmt.Errorf("write tags to %s: %w", mf.Path, err) + } + + if _, err := s.scanner.ScanFolders(ctx, false, []model.ScanTarget{scanTargetForMediaFile(*mf)}); err != nil { + return nil, fmt.Errorf("rescan song folder: %w", err) + } + + refreshed, err := s.reloadSongByPath(ctx, mf.Path) + if err == nil { + return songToPayload(refreshed), nil + } + return &payload, nil +} + +func (s *Service) GetAlbum(ctx context.Context, id string) (*AlbumPayload, error) { + album, err := s.ds.Album(ctx).Get(id) + if err != nil { + return nil, fmt.Errorf("get album %s: %w", id, err) + } + return albumToPayload(*album), nil +} + +func (s *Service) UpdateAlbum(ctx context.Context, id string, payload AlbumPayload) (*AlbumPayload, error) { + _, err := s.ds.Album(ctx).Get(id) + if err != nil { + return nil, fmt.Errorf("get album %s: %w", id, err) + } + + tracks, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album_id": id}, + }) + if err != nil { + return nil, fmt.Errorf("load album tracks: %w", err) + } + if len(tracks) == 0 { + return nil, fmt.Errorf("album %s has no tracks", id) + } + + targetMap := map[model.ScanTarget]struct{}{} + paths := make([]string, 0, len(tracks)) + + for _, mf := range tracks { + if mf.Missing { + continue + } + tags, err := taglib.ReadTags(mf.AbsolutePath()) + if err != nil { + return nil, fmt.Errorf("read tags from %s: %w", mf.Path, err) + } + applyAlbumPayload(tags, payload) + if err := taglib.WriteTags(mf.AbsolutePath(), tags, taglib.Clear); err != nil { + return nil, fmt.Errorf("write tags to %s: %w", mf.Path, err) + } + targetMap[scanTargetForMediaFile(mf)] = struct{}{} + paths = append(paths, mf.Path) + } + + targets := make([]model.ScanTarget, 0, len(targetMap)) + for target := range targetMap { + targets = append(targets, target) + } + if len(targets) > 0 { + if _, err := s.scanner.ScanFolders(ctx, false, targets); err != nil { + return nil, fmt.Errorf("rescan album folders: %w", err) + } + } + + if refreshed, err := s.reloadAlbumByPaths(ctx, paths); err == nil { + return refreshed, nil + } + + payload.ID = id + payload.SongCount = len(tracks) + return &payload, nil +} + +func (s *Service) reloadSongByPath(ctx context.Context, relPath string) (model.MediaFile, error) { + files, err := s.ds.MediaFile(ctx).FindByPaths([]string{relPath}) + if err != nil { + return model.MediaFile{}, err + } + if len(files) == 0 { + return model.MediaFile{}, fmt.Errorf("song not found after rescan: %s", relPath) + } + return files[0], nil +} + +func (s *Service) reloadAlbumByPaths(ctx context.Context, relPaths []string) (*AlbumPayload, error) { + files, err := s.ds.MediaFile(ctx).FindByPaths(relPaths) + if err != nil { + return nil, err + } + if len(files) == 0 { + return nil, fmt.Errorf("album not found after rescan") + } + albumID := files[0].AlbumID + return s.GetAlbum(ctx, albumID) +} + +func songToPayload(mf model.MediaFile) *SongPayload { + return &SongPayload{ + ID: mf.ID, + Path: mf.Path, + Title: mf.Title, + Artist: joinValues(trackArtistValues(mf)), + Album: mf.Album, + AlbumArtist: joinValues(albumArtistValues(mf)), + TrackNumber: intToString(mf.TrackNumber), + DiscNumber: intToString(mf.DiscNumber), + Date: firstNonEmpty(mf.Date, firstTagValue(mf.Tags, model.TagRecordingDate)), + ReleaseDate: firstNonEmpty(mf.ReleaseDate, firstTagValue(mf.Tags, model.TagReleaseDate)), + OriginalDate: firstNonEmpty(mf.OriginalDate, firstTagValue(mf.Tags, model.TagOriginalDate)), + Genre: joinValues(tagValues(mf.Tags, model.TagGenre, mf.Genre)), + Comment: mf.Comment, + Lyrics: mf.Lyrics, + } +} + +func albumToPayload(album model.Album) *AlbumPayload { + return &AlbumPayload{ + ID: album.ID, + Name: album.Name, + AlbumArtist: joinValues(albumLevelArtistValues(album)), + Date: firstNonEmpty(album.Date), + ReleaseDate: firstNonEmpty(album.ReleaseDate, firstTagValue(album.Tags, model.TagReleaseDate)), + OriginalDate: firstNonEmpty(album.OriginalDate, firstTagValue(album.Tags, model.TagOriginalDate)), + Genre: joinValues(tagValues(album.Tags, model.TagGenre, album.Genre)), + Comment: album.Comment, + Compilation: album.Compilation, + SongCount: album.SongCount, + } +} + +func applySongPayload(tags map[string][]string, payload SongPayload) { + setOrDelete(tags, taglib.Title, splitValues(payload.Title)) + setOrDelete(tags, taglib.Artist, splitValues(payload.Artist)) + setOrDelete(tags, taglib.Artists, splitValues(payload.Artist)) + setOrDelete(tags, taglib.Album, splitValues(payload.Album)) + setOrDelete(tags, taglib.AlbumArtist, splitValues(payload.AlbumArtist)) + setOrDelete(tags, taglib.TrackNumber, splitValues(payload.TrackNumber)) + setOrDelete(tags, taglib.DiscNumber, splitValues(payload.DiscNumber)) + setOrDelete(tags, taglib.Date, splitValues(payload.Date)) + setOrDelete(tags, taglib.ReleaseDate, splitValues(payload.ReleaseDate)) + setOrDelete(tags, taglib.OriginalDate, splitValues(payload.OriginalDate)) + setOrDelete(tags, taglib.Genre, splitValues(payload.Genre)) + setOrDelete(tags, taglib.Comment, splitValues(payload.Comment)) + setOrDelete(tags, taglib.Lyrics, splitValues(payload.Lyrics)) +} + +func applyAlbumPayload(tags map[string][]string, payload AlbumPayload) { + setOrDelete(tags, taglib.Album, splitValues(payload.Name)) + setOrDelete(tags, taglib.AlbumArtist, splitValues(payload.AlbumArtist)) + setOrDelete(tags, taglib.Date, splitValues(payload.Date)) + setOrDelete(tags, taglib.ReleaseDate, splitValues(payload.ReleaseDate)) + setOrDelete(tags, taglib.OriginalDate, splitValues(payload.OriginalDate)) + setOrDelete(tags, taglib.Genre, splitValues(payload.Genre)) + setOrDelete(tags, taglib.Comment, splitValues(payload.Comment)) + + if payload.Compilation { + setOrDelete(tags, taglib.Compilation, []string{"1"}) + } else { + delete(tags, taglib.Compilation) + } +} + +func scanTargetForMediaFile(mf model.MediaFile) model.ScanTarget { + dir := filepath.Dir(mf.Path) + if dir == "." { + dir = "" + } + return model.ScanTarget{ + LibraryID: mf.LibraryID, + FolderPath: dir, + } +} + +func tagValues(tags model.Tags, key model.TagName, fallback string) []string { + values := tags[key] + if len(values) > 0 { + return values + } + if fallback == "" { + return nil + } + return []string{fallback} +} + +func trackArtistValues(mf model.MediaFile) []string { + if values := mf.Tags[model.TagTrackArtists]; len(values) > 0 { + return values + } + if values := mf.Tags[model.TagTrackArtist]; len(values) > 0 { + return values + } + if mf.Artist == "" { + return nil + } + return []string{mf.Artist} +} + +func albumArtistValues(mf model.MediaFile) []string { + if values := mf.Tags[model.TagAlbumArtists]; len(values) > 0 { + return values + } + if values := mf.Tags[model.TagAlbumArtist]; len(values) > 0 { + return values + } + if mf.AlbumArtist == "" { + return nil + } + return []string{mf.AlbumArtist} +} + +func albumLevelArtistValues(album model.Album) []string { + if values := album.Tags[model.TagAlbumArtists]; len(values) > 0 { + return values + } + if values := album.Tags[model.TagAlbumArtist]; len(values) > 0 { + return values + } + if album.AlbumArtist == "" { + return nil + } + return []string{album.AlbumArtist} +} + +func firstTagValue(tags model.Tags, keys ...model.TagName) string { + for _, key := range keys { + if values := tags[key]; len(values) > 0 && strings.TrimSpace(values[0]) != "" { + return strings.TrimSpace(values[0]) + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func joinValues(values []string) string { + if len(values) == 0 { + return "" + } + return strings.Join(values, "; ") +} + +func setOrDelete(tags map[string][]string, key string, values []string) { + if len(values) == 0 { + delete(tags, key) + return + } + tags[key] = values +} + +func splitValues(value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + + raw := strings.FieldsFunc(value, func(r rune) bool { + return r == ';' || r == '\n' + }) + values := make([]string, 0, len(raw)) + seen := map[string]struct{}{} + for _, item := range raw { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + values = append(values, item) + } + return values +} + +func intToString(v int) string { + if v == 0 { + return "" + } + return strconv.Itoa(v) +} diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..43e1d910d 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -43,12 +43,13 @@ type Router struct { libs core.Library users core.User maintenance core.Maintenance + scanner model.Scanner pluginManager PluginManager imgUpload core.ImageUploadService } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, scanner model.Scanner, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, scanner: scanner, pluginManager: pluginManager, imgUpload: imgUpload} r.Handler = r.routes() return r } @@ -90,6 +91,7 @@ func (api *Router) routes() http.Handler { api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) + api.addTagEditorRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/nativeapi/tag_editor.go b/server/nativeapi/tag_editor.go new file mode 100644 index 000000000..54c2a9377 --- /dev/null +++ b/server/nativeapi/tag_editor.go @@ -0,0 +1,73 @@ +package nativeapi + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/tageditor" +) + +func (api *Router) addTagEditorRoute(r chi.Router) { + service := tageditor.New(api.ds, api.scanner) + + r.Route("/tag-editor", func(r chi.Router) { + r.Route("/song/{id}", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + payload, err := service.GetSong(r.Context(), chi.URLParam(r, "id")) + if err != nil { + writeTagEditorError(w, err, http.StatusBadRequest) + return + } + writeTagEditorJSON(w, http.StatusOK, payload) + }) + r.Put("/", func(w http.ResponseWriter, r *http.Request) { + var payload tageditor.SongPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeTagEditorError(w, err, http.StatusBadRequest) + return + } + updated, err := service.UpdateSong(r.Context(), chi.URLParam(r, "id"), payload) + if err != nil { + writeTagEditorError(w, err, http.StatusBadRequest) + return + } + writeTagEditorJSON(w, http.StatusOK, updated) + }) + }) + + r.Route("/album/{id}", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + payload, err := service.GetAlbum(r.Context(), chi.URLParam(r, "id")) + if err != nil { + writeTagEditorError(w, err, http.StatusBadRequest) + return + } + writeTagEditorJSON(w, http.StatusOK, payload) + }) + r.Put("/", func(w http.ResponseWriter, r *http.Request) { + var payload tageditor.AlbumPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeTagEditorError(w, err, http.StatusBadRequest) + return + } + updated, err := service.UpdateAlbum(r.Context(), chi.URLParam(r, "id"), payload) + if err != nil { + writeTagEditorError(w, err, http.StatusBadRequest) + return + } + writeTagEditorJSON(w, http.StatusOK, updated) + }) + }) + }) +} + +func writeTagEditorJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeTagEditorError(w http.ResponseWriter, err error, status int) { + writeTagEditorJSON(w, status, map[string]string{"error": err.Error()}) +} diff --git a/ui/src/album/AlbumActions.jsx b/ui/src/album/AlbumActions.jsx index 96cfab09a..ce44aef4c 100644 --- a/ui/src/album/AlbumActions.jsx +++ b/ui/src/album/AlbumActions.jsx @@ -28,6 +28,7 @@ import { import { formatBytes } from '../utils' import config from '../config' import { ToggleFieldsMenu } from '../common' +import { TagEditorAlbumButton } from '../tageditor' const useStyles = makeStyles({ toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' }, @@ -138,6 +139,7 @@ const AlbumActions = ({ )} +
{isNotSmall && }
diff --git a/ui/src/album/AlbumSongs.jsx b/ui/src/album/AlbumSongs.jsx index 8a7fd2ae4..127d10155 100644 --- a/ui/src/album/AlbumSongs.jsx +++ b/ui/src/album/AlbumSongs.jsx @@ -31,6 +31,7 @@ import { } from '../common' import config from '../config' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' +import { TagEditorSongDialog } from '../tageditor' import { removeAlbumCommentsFromSongs } from './utils.js' const useStyles = makeStyles( @@ -92,6 +93,7 @@ const AlbumSongs = (props) => { const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const classes = useStyles({ isDesktop }) const dispatch = useDispatch() + const [editingSongId, setEditingSongId] = React.useState(null) const version = useVersion() useResourceRefresh('song', 'album') @@ -194,6 +196,7 @@ const AlbumSongs = (props) => { > {columns} setEditingSongId(record.mediaFileId || record.id)} source={'starred'} sortable={false} className={classes.contextMenu} @@ -210,6 +213,11 @@ const AlbumSongs = (props) => { } /> + setEditingSongId(null)} + /> ) } diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx index ac5b10e13..63dce20bf 100644 --- a/ui/src/common/SongContextMenu.jsx +++ b/ui/src/common/SongContextMenu.jsx @@ -57,6 +57,7 @@ export const SongContextMenu = ({ record, showLove, onAddToPlaylist, + onEditTags, className, }) => { const classes = useStyles() @@ -68,6 +69,7 @@ export const SongContextMenu = ({ const [playlistAnchorEl, setPlaylistAnchorEl] = useState(null) const [playlists, setPlaylists] = useState([]) const [playlistsLoaded, setPlaylistsLoaded] = useState(false) + const [pendingEditTags, setPendingEditTags] = useState(false) const { permissions } = usePermissions() const redirect = useRedirect() @@ -143,6 +145,15 @@ export const SongContextMenu = ({ action: (record) => dispatch(openDownloadMenu(record, DOWNLOAD_MENU_SONG)), }, + editTags: { + enabled: permissions === 'admin' && !record.missing && Boolean(onEditTags), + label: 'Edit song tags', + action: () => { + setPendingEditTags(true) + setPlaylistAnchorEl(null) + setAnchorEl(null) + }, + }, info: { enabled: true, label: translate('resources.song.actions.info'), @@ -195,14 +206,15 @@ export const SongContextMenu = ({ e.stopPropagation() } - const handleItemClick = (e) => { + const handleItemClick = (key) => (e) => { e.preventDefault() - const key = e.target.getAttribute('value') const action = options[key].action if (key === 'showInPlaylist') { // For showInPlaylist, we keep the main menu open and show submenu action(record, e) + } else if (key === 'editTags') { + action(record) } else { // For other actions, close the main menu setAnchorEl(null) @@ -221,7 +233,16 @@ export const SongContextMenu = ({ const handleMainMenuClose = (e) => { setAnchorEl(null) setPlaylistAnchorEl(null) // Close both menus - e.stopPropagation() + if (e) { + e.stopPropagation() + } + } + + const handleMainMenuExited = () => { + if (pendingEditTags && onEditTags) { + setPendingEditTags(false) + onEditTags(record) + } } const handlePlaylistClick = (id, e) => { @@ -251,6 +272,7 @@ export const SongContextMenu = ({ anchorEl={anchorEl} open={open} onClose={handleMainMenuClose} + TransitionProps={{ onExited: handleMainMenuExited }} > {Object.keys(options).map((key) => { const showInPlaylistDisabled = @@ -263,7 +285,7 @@ export const SongContextMenu = ({ onClick={ showInPlaylistDisabled ? (e) => e.stopPropagation() - : handleItemClick + : handleItemClick(key) } disabled={showInPlaylistDisabled} style={ @@ -303,11 +325,13 @@ SongContextMenu.propTypes = { resource: PropTypes.string.isRequired, record: PropTypes.object.isRequired, onAddToPlaylist: PropTypes.func, + onEditTags: PropTypes.func, showLove: PropTypes.bool, } SongContextMenu.defaultProps = { onAddToPlaylist: () => {}, + onEditTags: null, record: {}, resource: 'song', showLove: true, diff --git a/ui/src/common/SongSimpleList.jsx b/ui/src/common/SongSimpleList.jsx index d398d3455..40dffc7b4 100644 --- a/ui/src/common/SongSimpleList.jsx +++ b/ui/src/common/SongSimpleList.jsx @@ -11,6 +11,7 @@ import { DurationField, SongContextMenu, RatingField } from './index' import { setTrack } from '../actions' import { useDispatch } from 'react-redux' import config from '../config' +import { TagEditorSongDialog } from '../tageditor' const useStyles = makeStyles( { @@ -64,10 +65,12 @@ export const SongSimpleList = ({ ...rest }) => { const dispatch = useDispatch() + const [editingSongId, setEditingSongId] = React.useState(null) const classes = useStyles({ classes: classesOverride }) return ( (loading || total > 0) && ( - + <> + {ids.map( (id) => data[id] && ( @@ -103,14 +106,26 @@ export const SongSimpleList = ({ /> - + + setEditingSongId(record.mediaFileId || record.id) + } + /> ), )} - + + setEditingSongId(null)} + /> + ) ) } diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx index bbe38b4d5..6c1eb934b 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -32,6 +32,7 @@ import { AlbumLinkField } from '../song/AlbumLinkField' import { playTracks } from '../actions' import PlaylistSongBulkActions from './PlaylistSongBulkActions' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' +import { TagEditorSongDialog } from '../tageditor' import config from '../config' const useStyles = makeStyles( @@ -97,6 +98,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const classes = useStyles({ isDesktop }) const dispatch = useDispatch() + const [editingSongId, setEditingSongId] = React.useState(null) const dataProvider = useDataProvider() const notify = useNotify() const version = useVersion() @@ -231,6 +233,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { > {columns} setEditingSongId(record.mediaFileId || record.id)} onAddToPlaylist={onAddToPlaylist} showLove={true} className={classes.contextMenu} @@ -240,6 +243,11 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { } /> + setEditingSongId(null)} + /> {React.cloneElement(props.pagination, listContext)} ) diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 98684132f..25779b973 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { AutocompleteArrayInput, Filter, @@ -37,6 +37,7 @@ import { AlbumLinkField } from './AlbumLinkField' import { SongBulkActions, QualityInfo, useSelectedFields } from '../common' import config from '../config' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' +import { TagEditorSongDialog } from '../tageditor' const useStyles = makeStyles({ contextHeader: { @@ -133,6 +134,7 @@ const SongFilter = (props) => { const SongList = (props) => { const classes = useStyles() const dispatch = useDispatch() + const [editingSongId, setEditingSongId] = useState(null) const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) useResourceRefresh('song') @@ -228,6 +230,7 @@ const SongList = (props) => { {columns} setEditingSongId(record.mediaFileId || record.id)} source={'starred_at'} sortByOrder={'DESC'} sortable={config.enableFavourites} @@ -245,6 +248,11 @@ const SongList = (props) => { )} } /> + setEditingSongId(null)} + /> ) } diff --git a/ui/src/tageditor/TagEditorAlbumButton.jsx b/ui/src/tageditor/TagEditorAlbumButton.jsx new file mode 100644 index 000000000..6ee6574a7 --- /dev/null +++ b/ui/src/tageditor/TagEditorAlbumButton.jsx @@ -0,0 +1,59 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Button } from 'react-admin' +import EditIcon from '@material-ui/icons/Edit' +import TagEditorDialog from './TagEditorDialog' + +const albumFields = [ + { name: 'name', label: 'Album title' }, + { + name: 'albumArtist', + label: 'Album artist(s)', + helperText: 'Separate multiple values with semicolons', + }, + { name: 'date', label: 'Recording date / year' }, + { name: 'releaseDate', label: 'Release date' }, + { name: 'originalDate', label: 'Original date' }, + { + name: 'genre', + label: 'Genre(s)', + helperText: 'Separate multiple values with semicolons', + }, + { name: 'comment', label: 'Comment', multiline: true, rows: 4 }, + { name: 'compilation', label: 'Compilation', type: 'boolean' }, +] + +const TagEditorAlbumButton = ({ albumId }) => { + const [open, setOpen] = React.useState(false) + + if (!albumId || localStorage.getItem('role') !== 'admin') { + return null + } + + return ( + <> + + setOpen(false)} + title="Edit album tags" + endpoint={`/tag-editor/album/${albumId}`} + fields={albumFields} + saveLabel="Save album tags" + onSaved={(data) => { + if (data?.id && data.id !== albumId) { + window.location.hash = `#/album/${data.id}/show` + } + }} + /> + + ) +} + +TagEditorAlbumButton.propTypes = { + albumId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, +} + +export default TagEditorAlbumButton diff --git a/ui/src/tageditor/TagEditorDialog.jsx b/ui/src/tageditor/TagEditorDialog.jsx new file mode 100644 index 000000000..4f7e6a396 --- /dev/null +++ b/ui/src/tageditor/TagEditorDialog.jsx @@ -0,0 +1,167 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useNotify, useRefresh } from 'react-admin' +import { + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Switch, + TextField, + Button, +} from '@material-ui/core' +import httpClient from '../dataProvider/httpClient' +import { REST_URL } from '../consts' + +const jsonHeaders = new Headers({ + Accept: 'application/json', + 'Content-Type': 'application/json', +}) + +const TagEditorDialog = ({ + open, + onClose, + title, + endpoint, + fields, + saveLabel, + onSaved, +}) => { + const notify = useNotify() + const refresh = useRefresh() + const [loading, setLoading] = React.useState(false) + const [saving, setSaving] = React.useState(false) + const [values, setValues] = React.useState({}) + + React.useEffect(() => { + if (!open) { + return + } + + setLoading(true) + httpClient(`${REST_URL}${endpoint}`) + .then(({ json }) => { + setValues(json || {}) + }) + .catch((error) => { + notify( + error?.body?.error || error?.message || 'Could not load current tags', + 'warning', + ) + onClose() + }) + .finally(() => { + setLoading(false) + }) + }, [endpoint, notify, onClose, open]) + + const handleFieldChange = React.useCallback( + (name, type = 'text') => + (event) => { + const value = type === 'boolean' ? event.target.checked : event.target.value + setValues((current) => ({ + ...current, + [name]: value, + })) + }, + [], + ) + + const handleSave = React.useCallback(async () => { + setSaving(true) + try { + const { json } = await httpClient(`${REST_URL}${endpoint}`, { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify(values), + }) + notify('Tags updated', 'info') + refresh() + onSaved && onSaved(json) + onClose() + } catch (error) { + notify( + error?.body?.error || error?.message || 'Could not save tags', + 'warning', + ) + } finally { + setSaving(false) + } + }, [endpoint, notify, onClose, onSaved, refresh, values]) + + return ( + + {title} + + {loading ? ( +
+ +
+ ) : ( + fields.map((field) => + field.type === 'boolean' ? ( + + } + label={field.label} + style={{ display: 'flex', marginBottom: 8 }} + /> + ) : ( + + ), + ) + )} +
+ + + + +
+ ) +} + +TagEditorDialog.propTypes = { + endpoint: PropTypes.string.isRequired, + fields: PropTypes.arrayOf(PropTypes.object).isRequired, + onClose: PropTypes.func.isRequired, + onSaved: PropTypes.func, + open: PropTypes.bool.isRequired, + saveLabel: PropTypes.string, + title: PropTypes.string.isRequired, +} + +TagEditorDialog.defaultProps = { + onSaved: null, + saveLabel: 'Save tags', +} + +export default TagEditorDialog diff --git a/ui/src/tageditor/TagEditorSongButton.jsx b/ui/src/tageditor/TagEditorSongButton.jsx new file mode 100644 index 000000000..41d571394 --- /dev/null +++ b/ui/src/tageditor/TagEditorSongButton.jsx @@ -0,0 +1,62 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Button } from 'react-admin' +import EditIcon from '@material-ui/icons/Edit' +import TagEditorDialog from './TagEditorDialog' + +const songFields = [ + { name: 'title', label: 'Title' }, + { + name: 'artist', + label: 'Artist(s)', + helperText: 'Separate multiple values with semicolons', + }, + { name: 'album', label: 'Album' }, + { + name: 'albumArtist', + label: 'Album artist(s)', + helperText: 'Separate multiple values with semicolons', + }, + { name: 'trackNumber', label: 'Track number' }, + { name: 'discNumber', label: 'Disc number' }, + { name: 'date', label: 'Recording date / year' }, + { name: 'releaseDate', label: 'Release date' }, + { name: 'originalDate', label: 'Original date' }, + { + name: 'genre', + label: 'Genre(s)', + helperText: 'Separate multiple values with semicolons', + }, + { name: 'comment', label: 'Comment', multiline: true, rows: 3 }, + { name: 'lyrics', label: 'Lyrics', multiline: true, rows: 6 }, +] + +const TagEditorSongButton = ({ songId }) => { + const [open, setOpen] = React.useState(false) + + if (!songId || localStorage.getItem('role') !== 'admin') { + return null + } + + return ( + <> + + setOpen(false)} + title="Edit song tags" + endpoint={`/tag-editor/song/${songId}`} + fields={songFields} + saveLabel="Save song tags" + /> + + ) +} + +TagEditorSongButton.propTypes = { + songId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, +} + +export default TagEditorSongButton diff --git a/ui/src/tageditor/TagEditorSongDialog.jsx b/ui/src/tageditor/TagEditorSongDialog.jsx new file mode 100644 index 000000000..ec742d450 --- /dev/null +++ b/ui/src/tageditor/TagEditorSongDialog.jsx @@ -0,0 +1,60 @@ +import React from 'react' +import PropTypes from 'prop-types' +import TagEditorDialog from './TagEditorDialog' + +const songFields = [ + { name: 'title', label: 'Title' }, + { + name: 'artist', + label: 'Artist(s)', + helperText: 'Separate multiple values with semicolons', + }, + { name: 'album', label: 'Album' }, + { + name: 'albumArtist', + label: 'Album artist(s)', + helperText: 'Separate multiple values with semicolons', + }, + { name: 'trackNumber', label: 'Track number' }, + { name: 'discNumber', label: 'Disc number' }, + { name: 'date', label: 'Recording date / year' }, + { name: 'releaseDate', label: 'Release date' }, + { name: 'originalDate', label: 'Original date' }, + { + name: 'genre', + label: 'Genre(s)', + helperText: 'Separate multiple values with semicolons', + }, + { name: 'comment', label: 'Comment', multiline: true, rows: 3 }, + { name: 'lyrics', label: 'Lyrics', multiline: true, rows: 6 }, +] + +const TagEditorSongDialog = ({ songId, open, onClose }) => { + if (!songId) { + return null + } + + return ( + + ) +} + +TagEditorSongDialog.propTypes = { + onClose: PropTypes.func.isRequired, + open: PropTypes.bool, + songId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), +} + +TagEditorSongDialog.defaultProps = { + open: false, + songId: null, +} + +export default TagEditorSongDialog diff --git a/ui/src/tageditor/index.js b/ui/src/tageditor/index.js new file mode 100644 index 000000000..8baeb497a --- /dev/null +++ b/ui/src/tageditor/index.js @@ -0,0 +1,3 @@ +export { default as TagEditorSongButton } from './TagEditorSongButton' +export { default as TagEditorAlbumButton } from './TagEditorAlbumButton' +export { default as TagEditorSongDialog } from './TagEditorSongDialog'