Merge 1b11bdfdc6a537d7b033f02f10e88123e8902d59 into d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e

This commit is contained in:
yashipashi 2026-08-01 07:14:03 +08:00 committed by GitHub
commit c5cd2e3ca7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 870 additions and 11 deletions

View File

@ -83,7 +83,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
}

368
core/tageditor/service.go Normal file
View File

@ -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)
}

View File

@ -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
}
@ -91,6 +92,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)
})
})

View File

@ -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()})
}

View File

@ -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 = ({
<CloudDownloadOutlinedIcon />
</AlbumButton>
)}
<TagEditorAlbumButton albumId={record.id} />
</div>
<div>{isNotSmall && <ToggleFieldsMenu resource="albumSong" />}</div>
</div>

View File

@ -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}
<SongContextMenu
onEditTags={(record) => setEditingSongId(record.mediaFileId || record.id)}
source={'starred'}
sortable={false}
className={classes.contextMenu}
@ -210,6 +213,11 @@ const AlbumSongs = (props) => {
</Card>
</div>
<ExpandInfoDialog content={<SongInfo />} />
<TagEditorSongDialog
open={Boolean(editingSongId)}
songId={editingSongId}
onClose={() => setEditingSongId(null)}
/>
</>
)
}

View File

@ -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,

View File

@ -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) && (
<List className={className} {...sanitizeListRestProps(rest)}>
<>
<List className={className} {...sanitizeListRestProps(rest)}>
{ids.map(
(id) =>
data[id] && (
@ -103,14 +106,26 @@ export const SongSimpleList = ({
/>
<ListItemSecondaryAction className={classes.rightIcon}>
<ListItemIcon>
<SongContextMenu record={data[id]} visible={true} />
<SongContextMenu
record={data[id]}
visible={true}
onEditTags={(record) =>
setEditingSongId(record.mediaFileId || record.id)
}
/>
</ListItemIcon>
</ListItemSecondaryAction>
</ListItem>
</span>
),
)}
</List>
</List>
<TagEditorSongDialog
open={Boolean(editingSongId)}
songId={editingSongId}
onClose={() => setEditingSongId(null)}
/>
</>
)
)
}

View File

@ -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}
<SongContextMenu
onEditTags={(record) => setEditingSongId(record.mediaFileId || record.id)}
onAddToPlaylist={onAddToPlaylist}
showLove={true}
className={classes.contextMenu}
@ -240,6 +243,11 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
</Card>
</div>
<ExpandInfoDialog content={<SongInfo />} />
<TagEditorSongDialog
open={Boolean(editingSongId)}
songId={editingSongId}
onClose={() => setEditingSongId(null)}
/>
{React.cloneElement(props.pagination, listContext)}
</>
)

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import {
AutocompleteArrayInput,
Filter,
@ -38,6 +38,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')
@ -234,6 +236,7 @@ const SongList = (props) => {
<SongTitleField source="title" showTrackNumbers={false} />
{columns}
<SongContextMenu
onEditTags={(record) => setEditingSongId(record.mediaFileId || record.id)}
source={'starred_at'}
sortByOrder={'DESC'}
sortable={config.enableFavourites}
@ -251,6 +254,11 @@ const SongList = (props) => {
)}
</List>
<ExpandInfoDialog content={<SongInfo />} />
<TagEditorSongDialog
open={Boolean(editingSongId)}
songId={editingSongId}
onClose={() => setEditingSongId(null)}
/>
</>
)
}

View File

@ -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 (
<>
<Button label="Edit album tags" onClick={() => setOpen(true)}>
<EditIcon />
</Button>
<TagEditorDialog
open={open}
onClose={() => 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

View File

@ -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 (
<Dialog open={open} onClose={saving ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle>{title}</DialogTitle>
<DialogContent dividers>
{loading ? (
<div
style={{
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
minHeight: 160,
}}
>
<CircularProgress size={28} />
</div>
) : (
fields.map((field) =>
field.type === 'boolean' ? (
<FormControlLabel
key={field.name}
control={
<Switch
checked={Boolean(values[field.name])}
color="primary"
onChange={handleFieldChange(field.name, 'boolean')}
/>
}
label={field.label}
style={{ display: 'flex', marginBottom: 8 }}
/>
) : (
<TextField
key={field.name}
fullWidth
margin="dense"
multiline={field.multiline}
rows={field.rows}
label={field.label}
helperText={field.helperText}
value={values[field.name] || ''}
onChange={handleFieldChange(field.name)}
/>
),
)
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button color="primary" onClick={handleSave} disabled={loading || saving}>
{saving ? <CircularProgress size={18} /> : saveLabel}
</Button>
</DialogActions>
</Dialog>
)
}
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

View File

@ -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 (
<>
<Button label="Edit tags" onClick={() => setOpen(true)}>
<EditIcon />
</Button>
<TagEditorDialog
open={open}
onClose={() => 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

View File

@ -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 (
<TagEditorDialog
open={open}
onClose={onClose}
title="Edit song tags"
endpoint={`/tag-editor/song/${songId}`}
fields={songFields}
saveLabel="Save song tags"
/>
)
}
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

View File

@ -0,0 +1,3 @@
export { default as TagEditorSongButton } from './TagEditorSongButton'
export { default as TagEditorAlbumButton } from './TagEditorAlbumButton'
export { default as TagEditorSongDialog } from './TagEditorSongDialog'