Files
2026-08-23 14:13:18 -05:00

279 lines
9.0 KiB
Go

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