mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge 1b11bdfdc6a537d7b033f02f10e88123e8902d59 into d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e
This commit is contained in:
commit
c5cd2e3ca7
@ -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
368
core/tageditor/service.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
73
server/nativeapi/tag_editor.go
Normal file
73
server/nativeapi/tag_editor.go
Normal 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()})
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
59
ui/src/tageditor/TagEditorAlbumButton.jsx
Normal file
59
ui/src/tageditor/TagEditorAlbumButton.jsx
Normal 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
|
||||
167
ui/src/tageditor/TagEditorDialog.jsx
Normal file
167
ui/src/tageditor/TagEditorDialog.jsx
Normal 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
|
||||
62
ui/src/tageditor/TagEditorSongButton.jsx
Normal file
62
ui/src/tageditor/TagEditorSongButton.jsx
Normal 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
|
||||
60
ui/src/tageditor/TagEditorSongDialog.jsx
Normal file
60
ui/src/tageditor/TagEditorSongDialog.jsx
Normal 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
|
||||
3
ui/src/tageditor/index.js
Normal file
3
ui/src/tageditor/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as TagEditorSongButton } from './TagEditorSongButton'
|
||||
export { default as TagEditorAlbumButton } from './TagEditorAlbumButton'
|
||||
export { default as TagEditorSongDialog } from './TagEditorSongDialog'
|
||||
Loading…
x
Reference in New Issue
Block a user