76d4074ca1
headCoverArt now returns (url, definitive) — transient failures (network errors, 5xx) are not cached, allowing immediate retries. Only definitive 404s are cached for caaCacheTTLMiss (4h).
208 lines
6.6 KiB
Go
208 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
|
)
|
|
|
|
// Cache TTLs for cover art lookups
|
|
const (
|
|
caaCacheTTLHit int64 = 24 * 60 * 60 // 24 hours for resolved CAA artwork
|
|
caaCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for CAA misses
|
|
uguuCacheTTL int64 = 150 * 60 // 2.5 hours for uguu.se uploads
|
|
|
|
caaTimeOut = 4000 // 4 seconds timeout for CAA HEAD requests to avoid blocking NowPlaying
|
|
)
|
|
|
|
// headCoverArt sends a HEAD request to the given CAA URL without following redirects.
|
|
// Returns (location, true) on 307 with a Location header (image exists),
|
|
// ("", true) on 404 (definitive miss — safe to cache),
|
|
// ("", false) on network errors or unexpected responses (transient — do not cache).
|
|
func headCoverArt(url string) (string, bool) {
|
|
resp, err := host.HTTPSend(host.HTTPRequest{
|
|
Method: "HEAD",
|
|
URL: url,
|
|
NoFollowRedirects: true,
|
|
TimeoutMs: caaTimeOut,
|
|
})
|
|
if err != nil {
|
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD request failed for %s: %v", url, err))
|
|
return "", false
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return "", true
|
|
}
|
|
if resp.StatusCode != 307 {
|
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD unexpected status %d for %s", resp.StatusCode, url))
|
|
return "", false
|
|
}
|
|
location := resp.Headers["Location"]
|
|
if location == "" {
|
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("CAA returned 307 but no Location header for %s", url))
|
|
}
|
|
return location, true
|
|
}
|
|
|
|
// getImageViaCoverArt checks the Cover Art Archive for album artwork.
|
|
// Tries the release first, then falls back to the release group.
|
|
// Returns the archive.org image URL on success, "" on failure.
|
|
func getImageViaCoverArt(mbzAlbumID, mbzReleaseGroupID string) string {
|
|
if mbzAlbumID == "" && mbzReleaseGroupID == "" {
|
|
return ""
|
|
}
|
|
|
|
// Determine cache key: use album ID when available, otherwise release group ID
|
|
cacheKey := "caa.artwork." + mbzAlbumID
|
|
if mbzAlbumID == "" {
|
|
cacheKey = "caa.artwork.rg." + mbzReleaseGroupID
|
|
}
|
|
|
|
// Check cache
|
|
cachedURL, exists, err := host.CacheGetString(cacheKey)
|
|
if err == nil && exists {
|
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA cache hit for %s", cacheKey))
|
|
return cachedURL
|
|
}
|
|
|
|
// Try release first
|
|
var imageURL string
|
|
definitive := false
|
|
if mbzAlbumID != "" {
|
|
imageURL, definitive = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release/%s/front-500", mbzAlbumID))
|
|
}
|
|
|
|
// Fall back to release group
|
|
if imageURL == "" && mbzReleaseGroupID != "" {
|
|
imageURL, definitive = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release-group/%s/front-500", mbzReleaseGroupID))
|
|
}
|
|
|
|
// Cache hits always; only cache misses if the response was definitive (404),
|
|
// not transient failures (network errors, 5xx) which should be retried sooner.
|
|
if imageURL != "" {
|
|
_ = host.CacheSetString(cacheKey, imageURL, caaCacheTTLHit)
|
|
} else if definitive {
|
|
_ = host.CacheSetString(cacheKey, "", caaCacheTTLMiss)
|
|
}
|
|
|
|
if imageURL != "" {
|
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA resolved artwork for %s: %s", cacheKey, imageURL))
|
|
}
|
|
|
|
return imageURL
|
|
}
|
|
|
|
// uguu.se API response
|
|
type uguuResponse struct {
|
|
Success bool `json:"success"`
|
|
Files []struct {
|
|
URL string `json:"url"`
|
|
} `json:"files"`
|
|
}
|
|
|
|
// getImageURL retrieves the track artwork URL, checking CAA first if enabled,
|
|
// then uguu.se, then direct Navidrome URL.
|
|
func getImageURL(username string, track scrobbler.TrackInfo) string {
|
|
caaEnabled, _ := pdk.GetConfig(caaEnabledKey)
|
|
if caaEnabled == "true" {
|
|
if url := getImageViaCoverArt(track.MBZAlbumID, track.MBZReleaseGroupID); url != "" {
|
|
return url
|
|
}
|
|
}
|
|
|
|
uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey)
|
|
if uguuEnabled == "true" {
|
|
return getImageViaUguu(username, track.ID)
|
|
}
|
|
|
|
return getImageDirect(track.ID)
|
|
}
|
|
|
|
// getImageDirect returns the artwork URL directly from Navidrome (current behavior).
|
|
func getImageDirect(trackID string) string {
|
|
artworkURL, err := host.ArtworkGetTrackUrl(trackID, 300)
|
|
if err != nil {
|
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %v", err))
|
|
return ""
|
|
}
|
|
|
|
// Don't use localhost URLs
|
|
if strings.HasPrefix(artworkURL, "http://localhost") {
|
|
return ""
|
|
}
|
|
return artworkURL
|
|
}
|
|
|
|
// getImageViaUguu fetches artwork and uploads it to uguu.se.
|
|
func getImageViaUguu(username, trackID string) string {
|
|
// Check cache first
|
|
cacheKey := fmt.Sprintf("uguu.artwork.%s", trackID)
|
|
cachedURL, exists, err := host.CacheGetString(cacheKey)
|
|
if err == nil && exists {
|
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for uguu artwork: %s", trackID))
|
|
return cachedURL
|
|
}
|
|
|
|
// Fetch artwork data from Navidrome
|
|
contentType, data, err := host.SubsonicAPICallRaw(fmt.Sprintf("/getCoverArt?u=%s&id=%s&size=300", username, trackID))
|
|
if err != nil {
|
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to fetch artwork data: %v", err))
|
|
return ""
|
|
}
|
|
|
|
// Upload to uguu.se
|
|
url, err := uploadToUguu(data, contentType)
|
|
if err != nil {
|
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to upload to uguu.se: %v", err))
|
|
return ""
|
|
}
|
|
|
|
_ = host.CacheSetString(cacheKey, url, uguuCacheTTL)
|
|
return url
|
|
}
|
|
|
|
// uploadToUguu uploads image data to uguu.se and returns the file URL.
|
|
func uploadToUguu(imageData []byte, contentType string) (string, error) {
|
|
// Build multipart/form-data body manually (TinyGo-compatible)
|
|
boundary := "----NavidromeCoverArt"
|
|
var body []byte
|
|
body = append(body, []byte(fmt.Sprintf("--%s\r\n", boundary))...)
|
|
body = append(body, []byte(fmt.Sprintf("Content-Disposition: form-data; name=\"files[]\"; filename=\"cover.jpg\"\r\n"))...)
|
|
body = append(body, []byte(fmt.Sprintf("Content-Type: %s\r\n", contentType))...)
|
|
body = append(body, []byte("\r\n")...)
|
|
body = append(body, imageData...)
|
|
body = append(body, []byte(fmt.Sprintf("\r\n--%s--\r\n", boundary))...)
|
|
|
|
resp, err := host.HTTPSend(host.HTTPRequest{
|
|
Method: "POST",
|
|
URL: "https://uguu.se/upload",
|
|
Headers: map[string]string{"Content-Type": fmt.Sprintf("multipart/form-data; boundary=%s", boundary)},
|
|
Body: body,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("uguu.se upload failed: %w", err)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var result uguuResponse
|
|
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
|
return "", fmt.Errorf("failed to parse uguu.se response: %w", err)
|
|
}
|
|
|
|
if !result.Success || len(result.Files) == 0 {
|
|
return "", fmt.Errorf("uguu.se upload was not successful")
|
|
}
|
|
|
|
if result.Files[0].URL == "" {
|
|
return "", fmt.Errorf("uguu.se returned empty URL")
|
|
}
|
|
|
|
return result.Files[0].URL, nil
|
|
}
|