weee
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user