package jellyfin
import (
"bytes"
"context"
"encoding/base64"
"errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"github.com/dustin/go-humanize"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/imghttp"
"github.com/navidrome/navidrome/utils/req"
_ "golang.org/x/image/webp"
)
func imageSize(p *req.Values) int {
fill := max(p.IntOr("fillwidth", 0), p.IntOr("fillheight", 0))
size := 0
for _, v := range []int{p.IntOr("width", 0), p.IntOr("height", 0), p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0), fill} {
if v > 0 && (size == 0 || v < size) {
size = v
}
}
return size
}
func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
ctx := request.WithUser(r.Context(), model.User{IsAdmin: true})
itemId, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
size := imageSize(req.Params(r))
artID := api.resolveArtworkID(ctx, itemId)
img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false)
switch {
case errors.Is(err, context.Canceled):
return
case err != nil:
log.Warn(ctx, "Error retrieving artwork", "id", itemId, err)
http.Error(w, "Not Found", http.StatusNotFound)
return
}
defer img.Close()
if imghttp.WriteImageHeaders(w, r, img, hashFromTag(r)) {
return
}
_, _ = io.Copy(w, img)
}
func hashFromTag(r *http.Request) string {
tag := r.URL.Query().Get("tag")
if len(tag) != 16 {
return ""
}
for _, c := range tag {
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
return ""
}
}
return tag
}
func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string {
if al, err := api.ds.Album().Get(ctx, itemId); err == nil {
return al.CoverArtID().String()
}
if ar, err := api.ds.Artist().Get(ctx, itemId); err == nil {
return ar.CoverArtID().String()
}
if mf, err := api.ds.MediaFile().Get(ctx, itemId); err == nil {
return mf.CoverArtID().String()
}
if pl, err := api.ds.Playlist().Get(ctx, itemId); err == nil {
return pl.CoverArtID().String()
}
return (model.ArtworkID{}).String()
}
func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
u, _ := request.UserFrom(ctx)
if !conf.Server.EnableArtworkUpload && !u.IsAdmin {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
limit := artwork.MaxImageUploadSize()
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit*4/3+4))
if err != nil {
log.Warn(ctx, "Jellyfin API: cover upload rejected: body exceeds MaxImageUploadSize",
"playlistId", id, "limit", humanize.Bytes(uint64(limit)), err)
http.Error(w, "file too large", http.StatusBadRequest)
return
}
if _, err := api.playlists.Get(ctx, id); err != nil {
http.Error(w, "Not Implemented", http.StatusNotImplemented)
return
}
imgBytes, err := decodeImageBody(body)
if err != nil {
log.Warn(ctx, "Jellyfin API: cover upload rejected: body is neither an image nor base64", "playlistId", id, err)
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if int64(len(imgBytes)) > limit {
log.Warn(ctx, "Jellyfin API: cover upload rejected: image exceeds MaxImageUploadSize",
"playlistId", id, "size", humanize.Bytes(uint64(len(imgBytes))), "limit", humanize.Bytes(uint64(limit)))
http.Error(w, "file too large", http.StatusBadRequest)
return
}
_, format, err := image.DecodeConfig(bytes.NewReader(imgBytes))
if err != nil {
log.Warn(ctx, "Jellyfin API: cover upload rejected: not a valid image", "playlistId", id, err)
http.Error(w, "invalid image file", http.StatusBadRequest)
return
}
ext := "." + format
if err := api.playlists.SetImage(ctx, id, bytes.NewReader(imgBytes), ext); err != nil {
api.internalError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
if _, err := api.playlists.Get(ctx, id); err != nil {
http.Error(w, "Not Implemented", http.StatusNotImplemented)
return
}
if err := api.playlists.RemoveImage(ctx, id); err != nil {
api.internalError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func decodeImageBody(body []byte) ([]byte, error) {
if isImageMagic(body) {
return body, nil
}
trimmed := bytes.TrimSpace(body)
return base64.StdEncoding.DecodeString(string(trimmed))
}
func isImageMagic(b []byte) bool {
switch {
case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8:
return true
case bytes.HasPrefix(b, []byte{0x89, 'P', 'N', 'G'}):
return true
case bytes.HasPrefix(b, []byte("GIF8")):
return true
case len(b) >= 12 && bytes.HasPrefix(b, []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")):
return true
default:
return false
}
}