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 }