weee
This commit is contained in:
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -283,10 +284,10 @@ func (h *Web) handleAdminScanner(w http.ResponseWriter, r *http.Request) {
|
||||
// --- System settings ---
|
||||
|
||||
func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderAdminSettings(w, r, "", "")
|
||||
h.renderAdminSettings(w, r, "", "", nil)
|
||||
}
|
||||
|
||||
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string) {
|
||||
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string, photoResults []ArtistPhotoOutcome) {
|
||||
trusted := ""
|
||||
for i, p := range h.Config.ExtAuth.TrustedSources {
|
||||
if i > 0 {
|
||||
@@ -301,6 +302,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
|
||||
ExtAuthEnabled bool
|
||||
ExtAuthTrustedSources string
|
||||
ExtAuthHeader string
|
||||
ArtistPhotosEnabled bool
|
||||
ArtistPhotoResults []ArtistPhotoOutcome
|
||||
}{
|
||||
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
|
||||
FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath),
|
||||
@@ -308,6 +311,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
|
||||
ExtAuthEnabled: h.Config.ExtAuth.Enabled,
|
||||
ExtAuthTrustedSources: trusted,
|
||||
ExtAuthHeader: h.Config.ExtAuth.UserHeader,
|
||||
ArtistPhotosEnabled: h.Config.EnableArtistPhotos,
|
||||
ArtistPhotoResults: photoResults,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -318,11 +323,52 @@ func (h *Web) handleAdminSettingsUpdate(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
interval := r.FormValue("scan_interval")
|
||||
if _, err := time.ParseDuration(interval); err != nil {
|
||||
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger")
|
||||
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger", nil)
|
||||
return
|
||||
}
|
||||
_ = database.SetSetting(h.DB, "scan_interval", interval)
|
||||
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
|
||||
_ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_path"))
|
||||
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success")
|
||||
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success", nil)
|
||||
}
|
||||
|
||||
// handleAdminArtistPhotosRefresh runs a synchronous, admin-triggered
|
||||
// fetch of every eligible artist's photo and reports the outcome for
|
||||
// each one — including failures — rather than the silent best-effort
|
||||
// background fetch normally used when an artist's page is viewed.
|
||||
func (h *Web) handleAdminArtistPhotosRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
// Unlike the best-effort background fetch on an artist's page (gated
|
||||
// by EnableArtistPhotos, since that one runs unattended on every
|
||||
// visit), this is an explicit, one-off admin action — it runs
|
||||
// regardless of the config toggle so it doubles as a way to check
|
||||
// what's actually happening (bad network, no confident name match,
|
||||
// an API error) without editing the environment first.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
results, err := h.RefreshAllArtistPhotos(ctx)
|
||||
if err != nil {
|
||||
h.renderAdminSettings(w, r, "Artist photo refresh failed: "+err.Error(), "danger", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var fetched, notFound, skipped, failed int
|
||||
for _, res := range results {
|
||||
switch {
|
||||
case res.Error != "":
|
||||
failed++
|
||||
case res.Status == "fetched":
|
||||
fetched++
|
||||
case res.Status == "no photo found":
|
||||
notFound++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
kind := "success"
|
||||
if failed > 0 {
|
||||
kind = "warning"
|
||||
}
|
||||
flash := fmt.Sprintf("Artist photo refresh complete: %d fetched, %d no photo found, %d skipped, %d failed (see below).",
|
||||
fetched, notFound, skipped, failed)
|
||||
h.renderAdminSettings(w, r, flash, kind, results)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
// --- Artists ---
|
||||
|
||||
func (h *Web) handleAdminArtistEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
artist, err := database.GetArtist(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_artist_edit", "artists", "Edit artist: "+artist.Name, artist)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminArtistUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetArtist(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxArtistPhotoBytes); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateArtist(h.DB, id, r.FormValue("name"), r.FormValue("sort_name"), r.FormValue("bio")); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.FormValue("remove_photo") == "1":
|
||||
if err := database.SetArtistCoverArt(h.DB, id, sql.NullString{}, false); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
default:
|
||||
if !h.applyPhotoOverride(w, r, "photo_file", "photo_url", func(localPath, contentHash, mimeType string) error {
|
||||
_, err := database.UpsertArtistCoverArt(h.DB, id, "external", localPath, mimeType, contentHash, 0, 0, true)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/artists/"+id, http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Albums ---
|
||||
|
||||
func (h *Web) handleAdminAlbumEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
album, err := database.GetAlbum(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_album_edit", "albums", "Edit album: "+album.Title, album)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminAlbumUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetAlbum(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxArtistPhotoBytes); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateAlbum(h.DB, id, r.FormValue("title"), r.FormValue("sort_title"),
|
||||
formNullInt(r, "year"), formNullInt(r, "original_year"), r.FormValue("record_label")); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.applyPhotoOverride(w, r, "cover_file", "cover_url", func(localPath, contentHash, mimeType string) error {
|
||||
_, err := database.UpsertAlbumCoverArt(h.DB, id, "external", localPath, "", mimeType, contentHash, 0, 0)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/albums/"+id, http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Songs ---
|
||||
|
||||
func (h *Web) handleAdminSongEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
song, err := database.GetSong(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_song_edit", "songs", "Edit song: "+song.Title, song)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminSongUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetSong(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
locked := r.FormValue("metadata_locked") == "1"
|
||||
if err := database.UpdateSongMetadata(h.DB, id, r.FormValue("title"), r.FormValue("sort_title"),
|
||||
formNullInt(r, "track_number"), formNullInt(r, "disc_number"), r.FormValue("composer"), r.FormValue("conductor"), locked); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/songs", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- shared helpers ---
|
||||
|
||||
// formNullInt parses a form field as an integer, returning an invalid
|
||||
// sql.NullInt64 when the field is blank or unparsable rather than
|
||||
// erroring the request — the same tolerant behavior as the rest of
|
||||
// VibeSonic's admin forms.
|
||||
func formNullInt(r *http.Request, name string) sql.NullInt64 {
|
||||
raw := strings.TrimSpace(r.FormValue(name))
|
||||
if raw == "" {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: n, Valid: true}
|
||||
}
|
||||
|
||||
// applyPhotoOverride implements the common "upload a file, or give a
|
||||
// URL, or leave the existing photo/cover alone" logic shared by the
|
||||
// artist and album edit forms. store is called with the resulting local
|
||||
// file's path/hash/mime once one is available. Returns false (having
|
||||
// already written an HTTP error response) on failure.
|
||||
func (h *Web) applyPhotoOverride(w http.ResponseWriter, r *http.Request, fileField, urlField string, store func(localPath, contentHash, mimeType string) error) bool {
|
||||
if file, _, err := r.FormFile(fileField); err == nil {
|
||||
defer file.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(file, maxArtistPhotoBytes+1))
|
||||
if err != nil {
|
||||
http.Error(w, "could not read uploaded image", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if len(body) > maxArtistPhotoBytes {
|
||||
http.Error(w, "uploaded image exceeds maximum allowed size", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
localPath, contentHash, mimeType, err := h.storeImageBytes(body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := store(localPath, contentHash, mimeType); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
photoURL := strings.TrimSpace(r.FormValue(urlField))
|
||||
if photoURL == "" {
|
||||
return true
|
||||
}
|
||||
if err := netguard.ValidateURL(photoURL); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
localPath, contentHash, mimeType, err := h.downloadImage(r.Context(), photoURL)
|
||||
if err != nil {
|
||||
http.Error(w, "could not download image: "+err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := store(localPath, contentHash, mimeType); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func (h *Web) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID)})
|
||||
views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin)})
|
||||
}
|
||||
h.page(w, r, "bookmarks", "bookmarks", "Bookmarks", views)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ func (h *Web) handleCoverArt(w http.ResponseWriter, r *http.Request) {
|
||||
// row, extracting embedded artwork to a temp file on demand. isTemp
|
||||
// tells the caller whether it must remove the returned path afterward.
|
||||
func (h *Web) resolveCoverArtSource(ctx context.Context, art *database.CoverArt) (path, contentHash string, isTemp bool, err error) {
|
||||
if art.Source == "folder" {
|
||||
if art.Source == "folder" || art.Source == "external" {
|
||||
// "external" is a locally-downloaded artist photo or an
|
||||
// admin-uploaded/URL-sourced cover override — already a plain
|
||||
// file on disk, same as a folder image.
|
||||
return art.SourcePath, art.ContentHash, false, nil
|
||||
}
|
||||
|
||||
|
||||
+12
-5
@@ -75,6 +75,7 @@ func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.maybeFetchArtistPhoto(artist)
|
||||
albums, err := database.ListAlbumsByArtist(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -85,9 +86,9 @@ func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
|
||||
views = append(views, toAlbumView(h.DB, a))
|
||||
}
|
||||
h.page(w, r, "artist", "artists", artist.Name, struct {
|
||||
Artist *database.Artist
|
||||
Artist ArtistView
|
||||
Albums []AlbumView
|
||||
}{artist, views})
|
||||
}{toArtistView(artist), views})
|
||||
}
|
||||
|
||||
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -126,12 +127,14 @@ func (h *Web) handleAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
userID := ""
|
||||
isAdmin := false
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
isAdmin = user.IsAdmin
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, album, userID))
|
||||
rows = append(rows, toSongRow(h.DB, s, album, userID, isAdmin))
|
||||
}
|
||||
starred := false
|
||||
if user != nil {
|
||||
@@ -157,12 +160,14 @@ func (h *Web) handleSongs(w http.ResponseWriter, r *http.Request) {
|
||||
songs = songs[:pageSize]
|
||||
}
|
||||
userID := ""
|
||||
isAdmin := false
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
isAdmin = user.IsAdmin
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID))
|
||||
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID, isAdmin))
|
||||
}
|
||||
h.page(w, r, "songs", "songs", "Songs", struct {
|
||||
Songs []SongRow
|
||||
@@ -219,14 +224,16 @@ func (h *Web) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if songIDRows != nil {
|
||||
defer songIDRows.Close()
|
||||
userID := ""
|
||||
isAdmin := false
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
isAdmin = user.IsAdmin
|
||||
}
|
||||
for songIDRows.Next() {
|
||||
var id string
|
||||
if songIDRows.Scan(&id) == nil {
|
||||
if song, err := database.GetSong(h.DB, id); err == nil {
|
||||
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID))
|
||||
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID, isAdmin))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (h *Web) handlePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin))
|
||||
}
|
||||
h.page(w, r, "playlist", "playlists", playlist.Name, struct {
|
||||
Playlist *database.Playlist
|
||||
@@ -109,7 +109,7 @@ func (h *Web) handleFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
var songs []SongRow
|
||||
for _, id := range songIDs {
|
||||
if s, err := database.GetSong(h.DB, id); err == nil {
|
||||
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID))
|
||||
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID, user.IsAdmin))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func (h *Web) handleRecent(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin))
|
||||
}
|
||||
h.page(w, r, "recent", "recent", "Recently Played", rows)
|
||||
}
|
||||
|
||||
@@ -34,3 +34,25 @@ func (h *Web) handlePodcastDownload(w http.ResponseWriter, r *http.Request) {
|
||||
go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }()
|
||||
http.Redirect(w, r, "/podcasts", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handlePodcastDownloadAll(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetPodcastChannel(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := h.Podcasts.DownloadChannel(context.Background(), id); err != nil && h.Log != nil {
|
||||
h.Log.Warn("podcast channel download failed", "channel", id, "err", err)
|
||||
}
|
||||
}()
|
||||
destination := "/podcasts"
|
||||
if next := r.FormValue("next"); next != "" {
|
||||
destination = safeRedirectPath(next)
|
||||
}
|
||||
http.Redirect(w, r, destination, http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func (h *Web) NewRouter() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /podcasts", h.requireUser(h.handlePodcasts))
|
||||
mux.HandleFunc("POST /podcasts/{id}/download", h.requireUser(h.handlePodcastDownload))
|
||||
mux.HandleFunc("POST /podcasts/{id}/download-all", h.requireUser(h.handlePodcastDownloadAll))
|
||||
|
||||
mux.HandleFunc("GET /radio", h.requireUser(h.handleRadio))
|
||||
mux.HandleFunc("POST /radio", h.requireAdmin(h.handleRadioCreate))
|
||||
@@ -92,6 +93,7 @@ func (h *Web) NewRouter() http.Handler {
|
||||
mux.HandleFunc("GET /admin/scanner", h.requireAdmin(h.handleAdminScanner))
|
||||
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
|
||||
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
|
||||
mux.HandleFunc("POST /admin/settings/artist-photos/refresh", h.requireAdmin(h.handleAdminArtistPhotosRefresh))
|
||||
|
||||
mux.HandleFunc("GET /admin/podcasts", h.requireAdmin(h.handleAdminPodcasts))
|
||||
mux.HandleFunc("POST /admin/podcasts", h.requireAdmin(h.handleAdminPodcastCreate))
|
||||
@@ -100,6 +102,13 @@ func (h *Web) NewRouter() http.Handler {
|
||||
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload))
|
||||
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete))
|
||||
|
||||
mux.HandleFunc("GET /admin/artists/{id}/edit", h.requireAdmin(h.handleAdminArtistEdit))
|
||||
mux.HandleFunc("POST /admin/artists/{id}/edit", h.requireAdmin(h.handleAdminArtistUpdate))
|
||||
mux.HandleFunc("GET /admin/albums/{id}/edit", h.requireAdmin(h.handleAdminAlbumEdit))
|
||||
mux.HandleFunc("POST /admin/albums/{id}/edit", h.requireAdmin(h.handleAdminAlbumUpdate))
|
||||
mux.HandleFunc("GET /admin/songs/{id}/edit", h.requireAdmin(h.handleAdminSongEdit))
|
||||
mux.HandleFunc("POST /admin/songs/{id}/edit", h.requireAdmin(h.handleAdminSongUpdate))
|
||||
|
||||
mux.HandleFunc("GET /admin/api-keys", h.requireAdmin(h.handleAdminAPIKeys))
|
||||
mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke))
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ func (h *Web) handleSharePage(w http.ResponseWriter, r *http.Request) {
|
||||
if serr != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), ""))
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), "", false))
|
||||
}
|
||||
|
||||
owner, _ := database.GetUserByID(h.DB, share.OwnerID)
|
||||
|
||||
+14
-5
@@ -10,8 +10,10 @@ import (
|
||||
// scanning details (sql.Null*) out of the .html files.
|
||||
|
||||
type ArtistView struct {
|
||||
ID string
|
||||
Name string
|
||||
ID string
|
||||
Name string
|
||||
Bio string
|
||||
CoverArtID string
|
||||
}
|
||||
|
||||
type AlbumView struct {
|
||||
@@ -32,10 +34,15 @@ type SongRow struct {
|
||||
TrackNumber int64
|
||||
DurationSeconds float64
|
||||
Starred bool
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
func toArtistView(a *database.Artist) ArtistView {
|
||||
return ArtistView{ID: a.ID, Name: a.Name}
|
||||
v := ArtistView{ID: a.ID, Name: a.Name, Bio: a.Bio}
|
||||
if a.CoverArtID.Valid {
|
||||
v.CoverArtID = a.CoverArtID.String
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
||||
@@ -56,12 +63,14 @@ func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
||||
|
||||
// toSongRow builds a display row for a song, given its owning album (may
|
||||
// be nil for albumless tracks) and the current user (for the starred
|
||||
// flag; pass "" userID to skip that lookup).
|
||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string) SongRow {
|
||||
// flag; pass "" userID to skip that lookup, and isAdmin=false to hide
|
||||
// the row's "Edit" link).
|
||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string, isAdmin bool) SongRow {
|
||||
row := SongRow{
|
||||
ID: s.ID,
|
||||
Title: s.Title,
|
||||
DurationSeconds: 0,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
if s.TrackNumber.Valid {
|
||||
row.TrackNumber = s.TrackNumber.Int64
|
||||
|
||||
Reference in New Issue
Block a user