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