🏡 index : quine-global/quintodrome.git

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"
)

// imageSize reduces Jellyfin's size params to Navidrome's single bound. Width/Height/Max* are
// bounds, so the tightest wins; Fill* must cover its box, so its larger side is the bound.
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) {
	// Public, like Jellyfin's own image routes: clients build cover URLs without credentials, and
	// upstream resolves them with no visibility check either (LibraryManager.ItemIsVisible, null user).
	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
	}
	// Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG).
	_, _ = io.Copy(w, img)
}

// hashFromTag returns the ?tag query param when it is exactly a 16-char lowercase-hex content
// hash (what Finamp/Jellyfin clients append), so a matching request can be served immutable.
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
}

// resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing
// album -> artist -> media file -> playlist.
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()
}

// postItemImage handles cover upload. Only playlists are writable here; album/artist covers come
// from scanning. Past the auth and id gates the body is drained before answering — including on the
// not-implemented path — because Finamp writes it synchronously and would see a broken pipe.
func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id, ok := itemIDParam(w, r, "itemId")
	if !ok {
		return
	}

	// Honor the same artwork-upload gate and size cap as the native endpoint.
	u, _ := request.UserFrom(ctx)
	if !conf.Server.EnableArtworkUpload && !u.IsAdmin {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return
	}
	// The limit caps the decoded image (native endpoint semantics); Jellyfin clients base64-encode
	// the wire body (4/3 bigger), so the read cap allows for inflation.
	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
	}
	// Validate by decoding and derive the extension from the real format — clients lie in Content-Type.
	_, 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)
}

// deleteItemImage removes a playlist's uploaded cover. Only playlists are supported.
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)
}

// decodeImageBody returns the raw image bytes. Jellyfin base64-encodes the body, but some clients
// send raw bytes, so input already starting with an image magic number is passed through as-is.
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: // JPEG
		return true
	case bytes.HasPrefix(b, []byte{0x89, 'P', 'N', 'G'}): // PNG
		return true
	case bytes.HasPrefix(b, []byte("GIF8")): // GIF (GIF87a/GIF89a)
		return true
	case len(b) >= 12 && bytes.HasPrefix(b, []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")): // WebP
		return true
	default:
		return false
	}
}