mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
* feat(playlist): add custom playlist cover art upload - #406 Allow users to upload, view, and remove custom cover images for playlists. Custom images take priority over the auto-generated tiled artwork. Backend: - Add `image_path` column to playlist table (migration with proper rollback) - Add `SetImage`/`RemoveImage` methods to playlist service - Add `POST/DELETE /api/playlist/{id}/image` endpoints - Prioritize custom image in artwork reader pipeline - Clean up image files on playlist deletion - Use glob-based cleanup to prevent orphaned files across format changes - Reject uploads with undetermined image type (400) Frontend: - Hover overlay on playlist cover with upload (camera) and remove (trash) buttons - Lightbox for full-size cover art viewing - Cover art thumbnails in the playlist list view - Loading/error states and i18n strings Closes #406 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> * refactor: rename playlist image path migration file Signed-off-by: Deluan <deluan@navidrome.org> * fix(playlist): address review feedback for cover art upload - #406 - Use httpClient instead of raw fetch for image upload/remove - Revert glob cleanup to simple imagePath check - Add log.Error before all error HTTP responses - Add backend tests for SetImage and RemoveImage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> * refactor(playlist): use Playlist.ArtworkPath() for image storage Migrate all playlist image path handling to use the new Playlist.ArtworkPath() method as the single source of truth. The DB now stores only the filename (e.g. "pls-1.jpg") instead of a relative path, and images are stored under {DataFolder}/artwork/playlist/ instead of {DataFolder}/playlist_images/. The artwork root directory is created at startup alongside DataFolder and CacheFolder. This also removes the conf dependency from reader_playlist.go since path resolution is now fully encapsulated in the model. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(playlist): streamline artwork image selection logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor: move translation keys, add pt-BR translations Signed-off-by: Deluan <deluan@navidrome.org> * refactor(playlist): rename image_path to image_file Rename the playlist cover art column and field from image_path/ImagePath to image_file/ImageFile across the migration, model, service, tests, and UI. The new name more accurately describes what the field stores (a filename, not a path) and aligns with the existing ImageFiles/IsImageFile naming conventions in the codebase. --------- Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org>
332 lines
10 KiB
Go
332 lines
10 KiB
Go
package nativeapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/deluan/rest"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/req"
|
|
_ "golang.org/x/image/webp"
|
|
)
|
|
|
|
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
|
|
|
func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
plsId := chi.URLParam(r, "playlistId")
|
|
tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r))
|
|
if tracks == nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
func getPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
|
handler := playlistTracksHandler(pls, rest.GetAll, func(r *http.Request) bool {
|
|
return req.Params(r).Int64Or("_start", 0) == 0
|
|
})
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" {
|
|
handleExportPlaylist(pls)(w, r)
|
|
return
|
|
}
|
|
handler(w, r)
|
|
}
|
|
}
|
|
|
|
func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc {
|
|
return playlistTracksHandler(pls, rest.Get, func(*http.Request) bool { return true })
|
|
}
|
|
|
|
func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
pl, err := pls.ImportM3U(ctx, r.Body)
|
|
if err != nil {
|
|
log.Error(r.Context(), "Error parsing playlist", err)
|
|
// TODO: consider returning StatusBadRequest for playlists that are malformed
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, err = w.Write([]byte(pl.ToM3U8())) //nolint:gosec
|
|
if err != nil {
|
|
log.Error(ctx, "Error sending m3u contents", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
plsId := chi.URLParam(r, "playlistId")
|
|
playlist, err := pls.GetWithTracks(ctx, plsId)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Warn(ctx, "Playlist not found", "playlistId", plsId)
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Error(ctx, "Error retrieving the playlist", "playlistId", plsId, err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name)
|
|
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
|
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name)
|
|
w.Header().Set("Content-Disposition", disposition)
|
|
|
|
_, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec
|
|
if err != nil {
|
|
log.Error(ctx, "Error sending playlist", "name", playlist.Name)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
p := req.Params(r)
|
|
playlistId, _ := p.String(":playlistId")
|
|
ids, _ := p.Strings("id")
|
|
err := pls.RemoveTracks(r.Context(), playlistId, ids)
|
|
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
|
log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0])
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeDeleteManyResponse(w, r, ids)
|
|
}
|
|
}
|
|
|
|
func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
|
type addTracksPayload struct {
|
|
Ids []string `json:"ids"`
|
|
AlbumIds []string `json:"albumIds"`
|
|
ArtistIds []string `json:"artistIds"`
|
|
Discs []model.DiscID `json:"discs"`
|
|
}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
playlistId, _ := p.String(":playlistId")
|
|
var payload addTracksPayload
|
|
err := json.NewDecoder(r.Body).Decode(&payload)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
count, c := 0, 0
|
|
if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
count += c
|
|
if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
count += c
|
|
if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
count += c
|
|
if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
count += c
|
|
|
|
// Must return an object with an ID, to satisfy ReactAdmin `create` call
|
|
_, err = fmt.Fprintf(w, `{"added":%d}`, count) //nolint:gosec
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func reorderItem(pls playlists.Playlists) http.HandlerFunc {
|
|
type reorderPayload struct {
|
|
InsertBefore string `json:"insert_before"`
|
|
}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
playlistId, _ := p.String(":playlistId")
|
|
id := p.IntOr(":id", 0)
|
|
if id == 0 {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var payload reorderPayload
|
|
err := json.NewDecoder(r.Body).Decode(&payload)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
newPos, err := strconv.Atoi(payload.InsertBefore)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
err = pls.ReorderTrack(ctx, playlistId, id, newPos)
|
|
if errors.Is(err, model.ErrNotAuthorized) {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
p := req.Params(r)
|
|
trackId, _ := p.String(":id")
|
|
playlists, err := svc.GetPlaylists(r.Context(), trackId)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data, err := json.Marshal(playlists)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = w.Write(data) //nolint:gosec
|
|
}
|
|
}
|
|
|
|
const maxImageSize = 10 << 20 // 10MB
|
|
|
|
func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
playlistId, _ := p.String(":id")
|
|
|
|
if err := r.ParseMultipartForm(maxImageSize); err != nil {
|
|
log.Error(ctx, "Error parsing multipart form", err)
|
|
http.Error(w, "file too large or invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("image")
|
|
if err != nil {
|
|
log.Error(ctx, "Error reading uploaded file", err)
|
|
http.Error(w, "missing image file", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Validate the uploaded file is a valid image
|
|
_, format, err := image.DecodeConfig(file)
|
|
if err != nil {
|
|
log.Error(ctx, "Uploaded file is not a valid image", err)
|
|
http.Error(w, "invalid image file", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Reset reader after DecodeConfig consumed some bytes
|
|
if seeker, ok := file.(io.Seeker); ok {
|
|
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
|
|
log.Error(ctx, "Error seeking file", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Determine file extension from decoded format or original filename
|
|
ext := "." + format
|
|
if ext == "." {
|
|
ext = strings.ToLower(filepath.Ext(header.Filename))
|
|
}
|
|
if ext == "" || ext == "." {
|
|
log.Error(ctx, "Could not determine image type", "playlistId", playlistId, "filename", header.Filename)
|
|
http.Error(w, "could not determine image type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
err = pls.SetImage(ctx, playlistId, file, ext)
|
|
if errors.Is(err, model.ErrNotAuthorized) {
|
|
log.Error(ctx, "Not authorized to upload playlist image", "playlistId", playlistId, err)
|
|
http.Error(w, "not authorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Error(ctx, "Playlist not found for image upload", "playlistId", playlistId, err)
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Error(ctx, "Error saving playlist image", "playlistId", playlistId, err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec
|
|
}
|
|
}
|
|
|
|
func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
playlistId, _ := p.String(":id")
|
|
|
|
err := pls.RemoveImage(ctx, playlistId)
|
|
if errors.Is(err, model.ErrNotAuthorized) {
|
|
log.Error(ctx, "Not authorized to remove playlist image", "playlistId", playlistId, err)
|
|
http.Error(w, "not authorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Error(ctx, "Playlist not found for image removal", "playlistId", playlistId, err)
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Error(ctx, "Error removing playlist image", "playlistId", playlistId, err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec
|
|
}
|
|
}
|