package web import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "net/http" "os" "path/filepath" "sort" "sync" "time" "git.noroutes.net/NoRoutes/vibetunes/internal/artistphoto" "git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/netguard" ) // maxArtistPhotoBytes bounds a single downloaded artist photo, the same // way internal/podcasts bounds enclosure downloads. const maxArtistPhotoBytes = 8 << 20 // artistPhotoCacheTTL mirrors internal/subsonic's enrichmentCacheTTL: // artist photos change essentially never, so a long TTL avoids // repeatedly hitting fanart.tv/Wikidata for the same artist. const artistPhotoCacheTTL = 30 * 24 * time.Hour var artistPhotoClient = &artistphoto.Client{} func (h *Web) artistPhotoDir() string { return filepath.Join(h.Config.DataDir, "artist-photos") } // maybeFetchArtistPhoto kicks off a best-effort background fetch of an // artist's photo the first time their page is viewed. It never blocks // the caller and is a no-op unless artist photos are enabled and no // photo (auto-fetched or manually set) is already on file — every // artist is attempted regardless of whether it has a tagged // MusicBrainz ID, since artistphoto.Client can resolve a photo by name // alone via Wikidata. func (h *Web) maybeFetchArtistPhoto(artist *database.Artist) { if !h.Config.EnableArtistPhotos || artist.CoverArtID.Valid || artist.PhotoManual { return } go h.fetchAndStoreArtistPhoto(artist) } func (h *Web) fetchAndStoreArtistPhoto(artist *database.Artist) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() if status, err := h.fetchAndStoreArtistPhotoResult(ctx, artist, false); err != nil { h.Log.Warn("artist photo fetch failed", "artist_id", artist.ID, "status", status, "error", err) } } // artistPhotoCacheKey is what a lookup result is cached/looked-up under: // the artist's MusicBrainz ID when tagged, or else a name-derived key // (real MBIDs are UUIDs, so this can never collide with one) — an // artist resolved by name still benefits from the same negative/ // positive caching as a tagged one. func artistPhotoCacheKey(artist *database.Artist) string { if artist.MusicBrainzID != "" { return artist.MusicBrainzID } return "name:" + artist.Name } // resolveArtistPhotoURL resolves a photo URL for artist, using the // cached result unless bypassCache is set (the manual "refresh all" // admin action bypasses it, so a previous failed/empty lookup doesn't // hide a photo that might exist now). func (h *Web) resolveArtistPhotoURL(ctx context.Context, artist *database.Artist, bypassCache bool) (string, error) { cacheKey := artistPhotoCacheKey(artist) if !bypassCache { if cached, ok := readArtistPhotoCache(h.DB, cacheKey); ok { return cached, nil } } u, err := artistPhotoClient.FetchPhotoURL(ctx, artist.MusicBrainzID, artist.Name, h.Config.FanartTVAPIKey) if err != nil { return "", err } if raw, err := json.Marshal(struct { URL string `json:"url"` }{u}); err == nil { _ = database.PutCachedExternalMetadata(h.DB, "artist_photo", cacheKey, "combined", string(raw)) } return u, nil } // fetchAndStoreArtistPhotoResult resolves and stores a photo for one // artist, returning a short human-readable status plus the underlying // error (if any) so callers that report to a person (RefreshAllArtistPhotos) // can show exactly what went wrong, not just log-and-forget. func (h *Web) fetchAndStoreArtistPhotoResult(ctx context.Context, artist *database.Artist, bypassCache bool) (status string, err error) { photoURL, err := h.resolveArtistPhotoURL(ctx, artist, bypassCache) if err != nil { return "lookup failed", err } if photoURL == "" { return "no photo found", nil } localPath, contentHash, mimeType, err := h.downloadImage(ctx, photoURL) if err != nil { return "download failed", err } if _, err := database.UpsertArtistCoverArt(h.DB, artist.ID, "external", localPath, mimeType, contentHash, 0, 0, false); err != nil { return "store failed", err } return "fetched", nil } // ArtistPhotoOutcome is the per-artist result of a bulk photo refresh // (see RefreshAllArtistPhotos), surfaced to an admin so failures are // visible instead of just falling back to the default placeholder with // no explanation. type ArtistPhotoOutcome struct { ArtistID string ArtistName string Status string Error string } // RefreshAllArtistPhotos attempts to (re-)fetch a photo for every // artist with no manual photo override, bypassing the cached "nothing // found" result so a previous failure doesn't silently persist // forever. A small worker pool keeps a large library from serializing // one slow network call after another. func (h *Web) RefreshAllArtistPhotos(ctx context.Context) ([]ArtistPhotoOutcome, error) { artists, err := database.ListArtists(h.DB) if err != nil { return nil, err } results := make([]ArtistPhotoOutcome, len(artists)) type job struct { idx int artist *database.Artist } jobs := make(chan job) const workers = 4 var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() for j := range jobs { results[j.idx] = h.refreshOneArtistPhoto(ctx, j.artist) } }() } for i, a := range artists { jobs <- job{i, a} } close(jobs) wg.Wait() sort.Slice(results, func(i, k int) bool { return results[i].ArtistName < results[k].ArtistName }) return results, nil } func (h *Web) refreshOneArtistPhoto(ctx context.Context, artist *database.Artist) ArtistPhotoOutcome { out := ArtistPhotoOutcome{ArtistID: artist.ID, ArtistName: artist.Name} if artist.PhotoManual { out.Status = "skipped (manual photo set)" return out } fetchCtx, cancel := context.WithTimeout(ctx, 120*time.Second) defer cancel() status, err := h.fetchAndStoreArtistPhotoResult(fetchCtx, artist, true) out.Status = status if err != nil { out.Error = err.Error() } return out } func readArtistPhotoCache(db *sql.DB, cacheKey string) (string, bool) { dataJSON, fetchedAt, found, err := database.GetCachedExternalMetadata(db, "artist_photo", cacheKey, "combined") if err != nil || !found || time.Since(fetchedAt) > artistPhotoCacheTTL { return "", false } var payload struct { URL string `json:"url"` } if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil { return "", false } return payload.URL, true } // downloadImage fetches sourceURL through an SSRF-safe client // (internal/netguard, the same protection used for podcast feeds and // enclosures) and hands the body to storeImageBytes. Used both for // auto-fetched artist photos and admin-supplied photo/cover URLs. func (h *Web) downloadImage(ctx context.Context, sourceURL string) (localPath, contentHash, mimeType string, err error) { if err := netguard.ValidateURL(sourceURL); err != nil { return "", "", "", err } client := netguard.NewSafeHTTPClient(15*time.Second, false) req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) if err != nil { return "", "", "", err } req.Header.Set("User-Agent", "VibeSonic/1.0 (+https://git.noroutes.net/NoRoutes/vibetunes)") resp, err := client.Do(req) if err != nil { return "", "", "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", "", "", fmt.Errorf("artist photo download: HTTP %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, maxArtistPhotoBytes+1)) if err != nil { return "", "", "", err } if len(body) > maxArtistPhotoBytes { return "", "", "", fmt.Errorf("artist photo exceeds maximum allowed size of %d bytes", maxArtistPhotoBytes) } return h.storeImageBytes(body) } // storeImageBytes validates that body actually decodes as an image, // then saves it under the artist-photos cache directory keyed by its // content hash (so re-fetching or re-uploading the same bytes reuses // the same file). Used both for artist photos and album cover // overrides — the directory name is a historical artifact, not a // restriction on what's stored there. func (h *Web) storeImageBytes(body []byte) (localPath, contentHash, mimeType string, err error) { if _, _, err := image.Decode(bytes.NewReader(body)); err != nil { return "", "", "", fmt.Errorf("not a valid image: %w", err) } mimeType = http.DetectContentType(body) sum := sha256.Sum256(body) contentHash = hex.EncodeToString(sum[:])[:16] dir := h.artistPhotoDir() if err := os.MkdirAll(dir, 0o755); err != nil { return "", "", "", err } localPath = filepath.Join(dir, contentHash+extensionForMIME(mimeType)) if _, err := os.Stat(localPath); err == nil { return localPath, contentHash, mimeType, nil } tmp := localPath + ".tmp" if err := os.WriteFile(tmp, body, 0o644); err != nil { return "", "", "", err } if err := os.Rename(tmp, localPath); err != nil { return "", "", "", err } return localPath, contentHash, mimeType, nil } func extensionForMIME(mimeType string) string { switch mimeType { case "image/png": return ".png" case "image/gif": return ".gif" default: return ".jpg" } }