279 lines
9.0 KiB
Go
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"
|
|
}
|
|
}
|