From 866f243f1bf0545380e29b10832da635a3df2431 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 14:13:18 -0500 Subject: [PATCH] weee --- docs/configuration.md | 4 +- docs/security.md | 31 ++- internal/artistphoto/client.go | 285 ++++++++++++++++++++ internal/artistphoto/client_test.go | 213 +++++++++++++++ internal/config/config.go | 19 +- internal/config/config_test.go | 14 + internal/coverart/coverart.go | 6 +- internal/coverart/coverart_test.go | 28 ++ internal/database/coverart.go | 35 ++- internal/database/library.go | 105 ++++++-- internal/database/subsonic_helpers.go | 2 +- internal/database/subsonic_helpers2.go | 6 +- internal/podcasts/podcasts.go | 24 ++ internal/web/admin_handlers.go | 54 +++- internal/web/admin_library_handlers.go | 195 ++++++++++++++ internal/web/artist_photo.go | 278 +++++++++++++++++++ internal/web/bookmarks_handlers.go | 2 +- internal/web/coverart_handler.go | 5 +- internal/web/pages.go | 17 +- internal/web/playlists.go | 6 +- internal/web/podcasts_handlers.go | 22 ++ internal/web/router.go | 9 + internal/web/share_handlers.go | 2 +- internal/web/views.go | 19 +- migrations/0005_artist_photos_and_edits.sql | 61 +++++ web/static/css/app.css | 21 ++ web/static/js/app.js | 130 ++++++++- web/templates/admin_album_edit.html | 51 ++++ web/templates/admin_artist_edit.html | 50 ++++ web/templates/admin_podcasts.html | 7 + web/templates/admin_settings.html | 37 +++ web/templates/admin_song_edit.html | 46 ++++ web/templates/album.html | 3 + web/templates/artist.html | 10 + web/templates/partials/cards.html | 10 +- web/templates/partials/header.html | 2 +- web/templates/podcasts.html | 10 +- 37 files changed, 1761 insertions(+), 58 deletions(-) create mode 100644 internal/artistphoto/client.go create mode 100644 internal/artistphoto/client_test.go create mode 100644 internal/web/admin_library_handlers.go create mode 100644 internal/web/artist_photo.go create mode 100644 migrations/0005_artist_photos_and_edits.sql create mode 100644 web/templates/admin_album_edit.html create mode 100644 web/templates/admin_artist_edit.html create mode 100644 web/templates/admin_song_edit.html diff --git a/docs/configuration.md b/docs/configuration.md index 07f7574..e4c17c0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,8 +16,10 @@ except the session secret. | `VIBESONIC_MPV_PATH` | `mpv` (from `PATH`) | Path to the `mpv` binary, used as the Subsonic jukebox's playback backend. If not found, the jukebox is administratively unavailable and `jukeboxControl` returns a clear error instead of a fake success. | | `VIBESONIC_SCAN_INTERVAL` | `1h` | How often to rescan enabled music folders, as a Go duration (`30m`, `6h`, ...). A startup scan always runs regardless of this value. | | `VIBESONIC_SESSION_SECRET` | *(none)* | Reserved for future use hardening session cookies beyond their database-backed random IDs. Not required to run, but recommended to set (e.g. `openssl rand -hex 32`) — VibeSonic logs a warning at startup if it's empty. | -| `VIBESONIC_COOKIE_SECURE` | `true` | Whether session/CSRF cookies get the `Secure` attribute. Set to `false` only for local development over plain HTTP. | +| `VIBESONIC_COOKIE_SECURE` | `false` | Whether session/CSRF cookies get the `Secure` attribute. Set to `true` when VibeSonic is served over HTTPS (including through a TLS-terminating reverse proxy); leave it off for direct plain-HTTP access. | | `VIBESONIC_ENABLE_EXTERNAL_METADATA` | `false` | Enable optional MusicBrainz/Cover Art Archive lookups for songs/albums that already carry a locally-tagged MusicBrainz ID. VibeSonic works fully offline with this off; it's the only feature that makes outbound Internet requests. | +| `VIBESONIC_ENABLE_ARTIST_PHOTOS` | `false` | Enable auto-fetching an artist photo for every artist with no manually-set one: fanart.tv (only when both `VIBESONIC_FANART_TV_API_KEY` is set and the artist has a tagged MusicBrainz ID), falling back to a direct name search against Wikidata/Wikimedia Commons — this never calls the MusicBrainz API itself. Off by default for the same offline-first reason as `VIBESONIC_ENABLE_EXTERNAL_METADATA`. An admin can always set a photo manually from an artist's edit page regardless of this setting, and can trigger a one-off bulk fetch (with per-artist results, including any errors) from Admin → System Settings regardless of this toggle. | +| `VIBESONIC_FANART_TV_API_KEY` | *(none)* | Optional personal [fanart.tv](https://fanart.tv/) API key, tried first (for artists with a tagged MusicBrainz ID) when `VIBESONIC_ENABLE_ARTIST_PHOTOS` is on. Leave unset to go straight to the Wikidata name search. | | `VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS` | `false` | Allow podcast feed/enclosure URLs to resolve to loopback/LAN/link-local addresses. Leave this off unless you intentionally host podcast feeds on a private network — see [`docs/security.md`](security.md#podcast-fetching-ssrf-protections). | ## External authentication diff --git a/docs/security.md b/docs/security.md index 0a4e145..4c9b29c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,8 +21,9 @@ authentication model in detail. comparison itself uses a constant-time compare (`crypto/subtle.ConstantTimeCompare`) to avoid a timing side-channel. - **API keys** are stored and looked up by hash, never in plaintext. -- **Sessions** are random opaque tokens in an HTTP-only, `Secure` - (configurable via `VIBESONIC_COOKIE_SECURE`, on by default) cookie. +- **Sessions** are random opaque tokens in an HTTP-only cookie. Set + `VIBESONIC_COOKIE_SECURE=true` for HTTPS deployments so browsers only send + session and CSRF cookies over secure connections. - **External (trusted-proxy) authentication** is off by default and, when enabled, trusts an identity-asserting header only from configured source IPs verified against the real TCP peer address — @@ -90,6 +91,32 @@ before the full image is decoded into memory, rejecting anything over 40 megapixels. This prevents a small, highly-compressed image file from forcing an out-of-proportion memory allocation ("decompression bomb"). +## Artist photos and manual metadata editing + +When `VIBESONIC_ENABLE_ARTIST_PHOTOS` is on, VibeSonic looks up an +artist photo from fanart.tv (only for an artist with both a tagged +MusicBrainz ID and `VIBESONIC_FANART_TV_API_KEY` set) or, failing +that, by searching Wikidata directly by artist name and reading the +matched entity's image from Wikimedia Commons — both +externally-hosted services, same trust model as the existing +MusicBrainz/Cover Art Archive lookups used elsewhere. This path +deliberately never calls the MusicBrainz API itself (its public +instance proved too rate-limited to depend on); a name-only match is +necessarily less precise, so it requires an exact (case-insensitive) +label match and only weakly prefers a music-flavored Wikidata +description, rather than fabricating a stricter guarantee it can't +actually make. The resulting image download, and any admin-supplied +photo/cover-art URL entered on an artist or album's edit page, is +fetched through the same `internal/netguard` SSRF-safe client described +above (dial-time IP validation, capped redirects, size-limited +response). Uploaded photo/cover files are validated by actually +decoding them as an image before being written to disk. + +Editing artist/album/song metadata (`/admin/artists/{id}/edit`, +`/admin/albums/{id}/edit`, `/admin/songs/{id}/edit`) and overriding a +photo/cover are admin-only actions, gated the same way as every other +`/admin/*` route. + ## Transcoding `ffmpeg`/`ffprobe` are invoked with `exec.CommandContext` and an diff --git a/internal/artistphoto/client.go b/internal/artistphoto/client.go new file mode 100644 index 0000000..0cf6693 --- /dev/null +++ b/internal/artistphoto/client.go @@ -0,0 +1,285 @@ +// Package artistphoto looks up an artist portrait URL by name (and, if +// available, MusicBrainz ID) from fanart.tv and Wikidata/Wikimedia +// Commons. It never talks to anything on a hot path and, like +// internal/metadataenrich, always degrades to "no photo found" rather +// than an error when a source has nothing to offer — only real +// transport failures are returned as errors. +package artistphoto + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const userAgent = "VibeSonic/1.0 (+https://git.noroutes.net/NoRoutes/vibetunes)" + +// commonsThumbnailWidth bounds the resolution requested from Wikimedia +// Commons for a Wikidata-sourced photo — plenty for an artist portrait, +// and small enough to comfortably clear internal/web's download size cap +// (a full-resolution original can be many times this). +const commonsThumbnailWidth = 800 + +const ( + defaultFanartTVBaseURL = "https://webservice.fanart.tv/v3/music" + defaultWikidataSearchBaseURL = "https://www.wikidata.org/w/api.php" + defaultWikidataEntityBaseURL = "https://www.wikidata.org/wiki/Special:EntityData" + defaultCommonsBaseURL = "https://commons.wikimedia.org/wiki/Special:FilePath" +) + +// musicDescriptionKeywords is a lightweight heuristic for preferring a +// Wikidata search hit that's actually a musician/band: a plain name +// search can just as easily match an actor, a film, or anyone else who +// happens to share the name. It's deliberately permissive — VibeSonic +// would rather occasionally attach a plausible-but-wrong photo (the +// caller asked for a best-effort name lookup, not a guarantee) than +// return nothing for an artist a stricter check would have found. +var musicDescriptionKeywords = []string{ + "band", "singer", "musician", "rapper", "songwriter", "duo", "group", + "orchestra", "rock", "pop", "hip hop", "hip-hop", "dj", "composer", "vocalist", +} + +// Client resolves an artist photo URL from fanart.tv (when both an API +// key and a locally-tagged MusicBrainz ID are available) or, that +// failing, by searching Wikidata directly by artist name. It +// deliberately never calls the MusicBrainz API: MusicBrainz's public +// instance is shared, heavily used, and prone to returning HTTP 503 +// under its own load regardless of how considerately a single client +// paces its requests, which made it an unreliable dependency for a +// feature that's supposed to degrade gracefully. The zero value talks +// to the real public services; the BaseURL fields exist so tests can +// point it at httptest servers instead. +type Client struct { + HTTPClient *http.Client + FanartTVBaseURL string + WikidataSearchBaseURL string + WikidataEntityBaseURL string + CommonsBaseURL string +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: 15 * time.Second} +} + +func (c *Client) fanartTVBaseURL() string { + if c.FanartTVBaseURL != "" { + return c.FanartTVBaseURL + } + return defaultFanartTVBaseURL +} + +func (c *Client) wikidataSearchBaseURL() string { + if c.WikidataSearchBaseURL != "" { + return c.WikidataSearchBaseURL + } + return defaultWikidataSearchBaseURL +} + +func (c *Client) wikidataEntityBaseURL() string { + if c.WikidataEntityBaseURL != "" { + return c.WikidataEntityBaseURL + } + return defaultWikidataEntityBaseURL +} + +func (c *Client) commonsBaseURL() string { + if c.CommonsBaseURL != "" { + return c.CommonsBaseURL + } + return defaultCommonsBaseURL +} + +// getWithRetry performs a GET and decodes a JSON response, retrying +// once on a transient transport error (a dropped connection or a slow +// response past the client timeout) rather than giving up on the first +// hiccup — free public services occasionally have one. +func (c *Client) getWithRetry(ctx context.Context, rawURL string, out any) (found bool, err error) { + for attempt := 0; ; attempt++ { + found, err = c.get(ctx, rawURL, out) + if err == nil || attempt >= 1 || ctx.Err() != nil { + return found, err + } + } +} + +func (c *Client) get(ctx context.Context, rawURL string, out any) (found bool, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return false, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return false, nil + } + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("%s: HTTP %d", rawURL, resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return false, err + } + return true, nil +} + +// FetchPhotoURL resolves an artist photo URL. When mbid is a +// locally-tagged MusicBrainz ID and fanartAPIKey is configured, +// fanart.tv (keyed directly by mbid — no MusicBrainz call involved) is +// tried first; otherwise, and as a fallback, name is searched directly +// against Wikidata. Returns ("", nil) — not an error — when no photo +// can be found through either source, since that's a normal, common +// outcome. +func (c *Client) FetchPhotoURL(ctx context.Context, mbid, name, fanartAPIKey string) (string, error) { + if mbid != "" && fanartAPIKey != "" { + u, err := c.fetchFanartTVPhoto(ctx, mbid, fanartAPIKey) + if err != nil { + return "", err + } + if u != "" { + return u, nil + } + } + return c.fetchWikidataPhotoByName(ctx, name) +} + +type fanartResponse struct { + ArtistThumb []struct { + URL string `json:"url"` + } `json:"artistthumb"` +} + +func (c *Client) fetchFanartTVPhoto(ctx context.Context, mbid, apiKey string) (string, error) { + u := fmt.Sprintf("%s/%s?api_key=%s", c.fanartTVBaseURL(), url.PathEscape(mbid), url.QueryEscape(apiKey)) + var raw fanartResponse + found, err := c.getWithRetry(ctx, u, &raw) + if err != nil || !found || len(raw.ArtistThumb) == 0 { + return "", err + } + return raw.ArtistThumb[0].URL, nil +} + +type wikidataSearchResponse struct { + Search []struct { + ID string `json:"id"` + Label string `json:"label"` + Description string `json:"description"` + } `json:"search"` +} + +// SearchArtistWikidataID looks up a Wikidata item ID (a "QID") by +// artist name, entirely independent of MusicBrainz. Only an exact +// (case-insensitive) label match is considered; among those, one whose +// description reads like a musician/band (see musicDescriptionKeywords) +// is preferred, falling back to the first exact match otherwise. No +// match at all — including a same-named but clearly unrelated entity — +// returns ("", nil). +func (c *Client) SearchArtistWikidataID(ctx context.Context, name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", nil + } + u := fmt.Sprintf("%s?action=wbsearchentities&search=%s&language=en&format=json&type=item&limit=8", + c.wikidataSearchBaseURL(), url.QueryEscape(name)) + var raw wikidataSearchResponse + found, err := c.getWithRetry(ctx, u, &raw) + if err != nil || !found { + return "", err + } + + var exact []struct { + ID string `json:"id"` + Label string `json:"label"` + Description string `json:"description"` + } + for _, r := range raw.Search { + if strings.EqualFold(r.Label, name) { + exact = append(exact, r) + } + } + if len(exact) == 0 { + return "", nil + } + for _, r := range exact { + desc := strings.ToLower(r.Description) + for _, kw := range musicDescriptionKeywords { + if strings.Contains(desc, kw) { + return r.ID, nil + } + } + } + return exact[0].ID, nil +} + +// wikidataEntityResponse only decodes each claim's raw JSON, not its +// value: a Wikidata entity carries many properties whose datavalue.value +// is an object (e.g. P31 "instance of" is an entity reference), not a +// string, so unmarshaling the whole claims map into one string-valued +// struct fails on the first non-string property it hits — long before +// ever reaching P18. Only the property actually needed (P18, the image) +// gets decoded into a concrete shape, on demand. +type wikidataEntityResponse struct { + Entities map[string]struct { + Claims map[string]json.RawMessage `json:"claims"` + } `json:"entities"` +} + +type wikidataImageClaim struct { + MainSnak struct { + DataValue struct { + Value string `json:"value"` + } `json:"datavalue"` + } `json:"mainsnak"` +} + +// fetchWikidataPhotoByName resolves a photo by searching Wikidata for +// name, then reading the matched entity's P18 (image) claim, then +// building Commons' canonical file-path URL for that filename. +func (c *Client) fetchWikidataPhotoByName(ctx context.Context, name string) (string, error) { + qid, err := c.SearchArtistWikidataID(ctx, name) + if err != nil || qid == "" { + return "", err + } + + entityURL := fmt.Sprintf("%s/%s.json", c.wikidataEntityBaseURL(), url.PathEscape(qid)) + var entity wikidataEntityResponse + found, err := c.getWithRetry(ctx, entityURL, &entity) + if err != nil || !found { + return "", err + } + + claims, ok := entity.Entities[qid] + if !ok { + return "", nil + } + p18Raw, ok := claims.Claims["P18"] + if !ok { + return "", nil + } + var images []wikidataImageClaim + if err := json.Unmarshal(p18Raw, &images); err != nil { + return "", err + } + if len(images) == 0 || images[0].MainSnak.DataValue.Value == "" { + return "", nil + } + filename := images[0].MainSnak.DataValue.Value + + // Special:FilePath serves the original file by default, which for a + // press photo can be a many-megapixel, many-megabyte original — ask + // for a reasonably sized rendition instead (Commons redirects this + // to the matching pre-generated thumbnail rather than resizing on + // the fly). + return fmt.Sprintf("%s/%s?width=%d", c.commonsBaseURL(), url.PathEscape(filename), commonsThumbnailWidth), nil +} diff --git a/internal/artistphoto/client_test.go b/internal/artistphoto/client_test.go new file mode 100644 index 0000000..dbe61d1 --- /dev/null +++ b/internal/artistphoto/client_test.go @@ -0,0 +1,213 @@ +package artistphoto + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchPhotoURLUsesFanartTVWhenMBIDAndKeyProvided(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/mbid-1" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if r.URL.Query().Get("api_key") != "secret" { + t.Errorf("api_key = %q", r.URL.Query().Get("api_key")) + } + json.NewEncoder(w).Encode(map[string]any{ + "artistthumb": []map[string]string{{"url": "https://fanart.example/photo.jpg"}}, + }) + })) + defer srv.Close() + + c := &Client{FanartTVBaseURL: srv.URL} + u, err := c.FetchPhotoURL(context.Background(), "mbid-1", "Some Artist", "secret") + if err != nil { + t.Fatalf("FetchPhotoURL: %v", err) + } + if u != "https://fanart.example/photo.jpg" { + t.Errorf("url = %q", u) + } +} + +func TestFetchPhotoURLSkipsFanartTVWithoutMBID(t *testing.T) { + fanartCalled := false + fanart := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fanartCalled = true + })) + defer fanart.Close() + + wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"search": []map[string]any{}}) + })) + defer wds.Close() + + c := &Client{FanartTVBaseURL: fanart.URL, WikidataSearchBaseURL: wds.URL} + if _, err := c.FetchPhotoURL(context.Background(), "", "Some Artist", "secret"); err != nil { + t.Fatalf("FetchPhotoURL: %v", err) + } + if fanartCalled { + t.Error("fanart.tv should never be called without a MusicBrainz ID (it has no name-search endpoint)") + } +} + +func TestFetchPhotoURLSearchesWikidataByName(t *testing.T) { + wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("search"); got != "The Fray" { + t.Errorf("search query = %q", got) + } + json.NewEncoder(w).Encode(map[string]any{ + "search": []map[string]any{ + {"id": "Q191571", "label": "The Fray", "description": "American rock band"}, + }, + }) + })) + defer wds.Close() + + wde := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/Q191571.json" { + t.Errorf("unexpected entity path %q", r.URL.Path) + } + json.NewEncoder(w).Encode(map[string]any{ + "entities": map[string]any{ + "Q191571": map[string]any{ + "claims": map[string]any{ + // A real Wikidata entity carries many other + // properties whose datavalue.value is an + // object, not a string (P31 "instance of" is + // an entity reference, for example) — this + // must not prevent P18 from being read. + "P31": []map[string]any{ + {"mainsnak": map[string]any{"datavalue": map[string]any{"value": map[string]any{"entity-type": "item", "id": "Q215380"}}}}, + }, + "P18": []map[string]any{ + {"mainsnak": map[string]any{"datavalue": map[string]any{"value": "The Fray.jpg"}}}, + }, + }, + }, + }, + }) + })) + defer wde.Close() + + commons := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer commons.Close() + + c := &Client{WikidataSearchBaseURL: wds.URL, WikidataEntityBaseURL: wde.URL, CommonsBaseURL: commons.URL} + u, err := c.FetchPhotoURL(context.Background(), "", "The Fray", "") + if err != nil { + t.Fatalf("FetchPhotoURL: %v", err) + } + want := commons.URL + "/The%20Fray.jpg?width=800" + if u != want { + t.Errorf("url = %q, want %q", u, want) + } +} + +func TestFetchPhotoURLFallsBackToWikidataWhenFanartHasNoPhoto(t *testing.T) { + fanart := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer fanart.Close() + + wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "search": []map[string]any{ + {"id": "Q1", "label": "Some Artist", "description": "American singer"}, + }, + }) + })) + defer wds.Close() + + wde := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"entities": map[string]any{"Q1": map[string]any{"claims": map[string]any{}}}}) + })) + defer wde.Close() + + c := &Client{FanartTVBaseURL: fanart.URL, WikidataSearchBaseURL: wds.URL, WikidataEntityBaseURL: wde.URL} + u, err := c.FetchPhotoURL(context.Background(), "mbid-2", "Some Artist", "secret") + if err != nil { + t.Fatalf("FetchPhotoURL: %v", err) + } + if u != "" { + t.Errorf("expected empty URL (no P18 claim), got %q", u) + } +} + +func TestSearchArtistWikidataIDPrefersMusicDescription(t *testing.T) { + wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "search": []map[string]any{ + {"id": "Q-actor", "label": "Gigi Perez", "description": "Filipino actor"}, + {"id": "Q-singer", "label": "Gigi Perez", "description": "American singer-songwriter"}, + }, + }) + })) + defer wds.Close() + + c := &Client{WikidataSearchBaseURL: wds.URL} + qid, err := c.SearchArtistWikidataID(context.Background(), "Gigi Perez") + if err != nil { + t.Fatalf("SearchArtistWikidataID: %v", err) + } + if qid != "Q-singer" { + t.Errorf("qid = %q, want the music-flavored match Q-singer", qid) + } +} + +func TestSearchArtistWikidataIDRequiresExactLabelMatch(t *testing.T) { + wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "search": []map[string]any{ + {"id": "Q-tribute", "label": "Not Related! with Luke Smith Tribute", "description": "cover band"}, + }, + }) + })) + defer wds.Close() + + c := &Client{WikidataSearchBaseURL: wds.URL} + qid, err := c.SearchArtistWikidataID(context.Background(), "Not Related! with Luke Smith") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if qid != "" { + t.Errorf("expected no match for a non-exact label, got %q", qid) + } +} + +func TestFetchWikidataPhotoByNameRetriesOnTransientError(t *testing.T) { + var calls int + wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + // Simulate a transient network hiccup by closing the + // connection without responding at all. + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatal("ResponseWriter does not support hijacking") + } + conn, _, err := hj.Hijack() + if err != nil { + t.Fatal(err) + } + conn.Close() + return + } + json.NewEncoder(w).Encode(map[string]any{"search": []map[string]any{}}) + })) + defer wds.Close() + + c := &Client{WikidataSearchBaseURL: wds.URL} + u, err := c.FetchPhotoURL(context.Background(), "", "Retry Artist", "") + if err != nil { + t.Fatalf("expected the retry to recover from the first request's transient failure, got %v", err) + } + if u != "" { + t.Errorf("expected empty URL (no search results), got %q", u) + } + if calls != 2 { + t.Errorf("expected exactly one retry (2 total calls), got %d calls", calls) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 005b096..8ece760 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,17 @@ type Config struct { // to the outside world. EnableExternalMetadata bool + // EnableArtistPhotos opts into looking up an artist photo (fanart.tv, + // falling back to Wikidata/Wikimedia Commons) for artists that carry + // a locally-tagged MusicBrainz ID. Off by default for the same + // offline-first reason as EnableExternalMetadata. + EnableArtistPhotos bool + + // FanartTVAPIKey is an optional personal API key for fanart.tv, + // tried before the free Wikidata fallback when EnableArtistPhotos is + // on. Empty means "skip fanart.tv". + FanartTVAPIKey string + // PodcastsAllowPrivateNetworks disables SSRF protection on podcast // feed/enclosure fetching, allowing feeds hosted on loopback/LAN/ // link-local addresses. Off by default: podcast URLs are supplied @@ -68,10 +79,16 @@ func Load() (Config, error) { FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"), MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"), SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"), - CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true), + // The built-in server speaks plain HTTP, so Secure cookies cannot be + // the default: browsers would discard them during first-run setup. + // Deployments terminating TLS at a reverse proxy should opt in. + CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", false), EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false), + EnableArtistPhotos: envBool("VIBESONIC_ENABLE_ARTIST_PHOTOS", false), + FanartTVAPIKey: os.Getenv("VIBESONIC_FANART_TV_API_KEY"), + PodcastsAllowPrivateNetworks: envBool("VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS", false), } cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db")) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b537de5..10aff8d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,6 +25,9 @@ func TestLoadDefaults(t *testing.T) { if cfg.DBPath != "/var/lib/vibesonic/vibesonic.db" { t.Errorf("DBPath = %q, want default under DataDir", cfg.DBPath) } + if cfg.CookieSecure { + t.Error("CookieSecure = true, want false for the default plain-HTTP server") + } if cfg.ExtAuth.Enabled { t.Errorf("ExtAuth.Enabled = true, want false by default") } @@ -33,6 +36,17 @@ func TestLoadDefaults(t *testing.T) { } } +func TestLoadSecureCookiesOptIn(t *testing.T) { + t.Setenv("VIBESONIC_COOKIE_SECURE", "true") + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.CookieSecure { + t.Error("CookieSecure = false, want true when explicitly enabled") + } +} + func TestLoadMusicDirs(t *testing.T) { withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "}) cfg, err := Load() diff --git a/internal/coverart/coverart.go b/internal/coverart/coverart.go index b8c7352..f4d55f1 100644 --- a/internal/coverart/coverart.go +++ b/internal/coverart/coverart.go @@ -77,7 +77,11 @@ func (s *Store) ExtractEmbedded(ctx context.Context, mediaPath string, streamInd "-map", fmt.Sprintf("0:%d", streamIndex), "-frames:v", "1", "-c:v", "copy", - "--", tmp.Name(), + // Temporary files deliberately have a neutral .img suffix. Tell + // ffmpeg which muxer to use instead of asking it to infer one from + // that suffix. ffmpeg does not accept `--` as an end-of-options + // marker, so including it here made every embedded-art request fail. + "-f", "image2", tmp.Name(), ) if out, err := cmd.CombinedOutput(); err != nil { os.Remove(tmp.Name()) diff --git a/internal/coverart/coverart_test.go b/internal/coverart/coverart_test.go index f80a564..ce54342 100644 --- a/internal/coverart/coverart_test.go +++ b/internal/coverart/coverart_test.go @@ -1,6 +1,7 @@ package coverart import ( + "context" "image" "image/color" "image/jpeg" @@ -9,6 +10,33 @@ import ( "testing" ) +func TestExtractEmbeddedSuppliesExplicitOutputFormat(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + fakeFFmpeg := filepath.Join(dir, "ffmpeg") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGS_PATH\"\n" + if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("ARGS_PATH", argsPath) + + store := New(dir, fakeFFmpeg) + path, err := store.ExtractEmbedded(context.Background(), filepath.Join(dir, "song.mp3"), 2) + if err != nil { + t.Fatalf("ExtractEmbedded: %v", err) + } + defer os.Remove(path) + + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + got := string(args) + if got != "-y\n-v\nerror\n-i\n"+filepath.Join(dir, "song.mp3")+"\n-map\n0:2\n-frames:v\n1\n-c:v\ncopy\n-f\nimage2\n"+path+"\n" { + t.Fatalf("unexpected ffmpeg arguments:\n%s", got) + } +} + func writeTestJPEG(t *testing.T, path string, w, h int) { t.Helper() img := image.NewRGBA(image.Rect(0, 0, w, h)) diff --git a/internal/database/coverart.go b/internal/database/coverart.go index f7df161..64d665b 100644 --- a/internal/database/coverart.go +++ b/internal/database/coverart.go @@ -11,6 +11,7 @@ type CoverArt struct { ID string AlbumID sql.NullString MediaFileID sql.NullString + ArtistID sql.NullString Source string SourcePath string MimeType string @@ -19,7 +20,7 @@ type CoverArt struct { Height int } -const coverArtSelect = `SELECT id, album_id, media_file_id, source, source_path, mime_type, content_hash, width, height FROM cover_art` +const coverArtSelect = `SELECT id, album_id, media_file_id, artist_id, source, source_path, mime_type, content_hash, width, height FROM cover_art` func GetCoverArt(db *sql.DB, id string) (*CoverArt, error) { return scanCoverArt(db.QueryRow(coverArtSelect+` WHERE id = ?`, id)) @@ -31,7 +32,7 @@ func GetCoverArtForAlbum(db *sql.DB, albumID string) (*CoverArt, error) { func scanCoverArt(row *sql.Row) (*CoverArt, error) { var c CoverArt - err := row.Scan(&c.ID, &c.AlbumID, &c.MediaFileID, &c.Source, &c.SourcePath, &c.MimeType, &c.ContentHash, &c.Width, &c.Height) + err := row.Scan(&c.ID, &c.AlbumID, &c.MediaFileID, &c.ArtistID, &c.Source, &c.SourcePath, &c.MimeType, &c.ContentHash, &c.Width, &c.Height) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -71,6 +72,36 @@ func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mediaFileID, m return GetCoverArt(db, id) } +// UpsertArtistCoverArt records (or replaces) an artist's photo and links +// it as the artist's cover art. manual=true marks it as an admin +// override (see database.SetArtistCoverArt); manual=false is the +// auto-fetch path, which never overwrites an existing manual override. +func UpsertArtistCoverArt(db *sql.DB, artistID, source, sourcePath, mimeType, contentHash string, width, height int, manual bool) (*CoverArt, error) { + var id string + err := db.QueryRow(`SELECT id FROM cover_art WHERE artist_id = ?`, artistID).Scan(&id) + switch { + case err == nil: + _, err = db.Exec(`UPDATE cover_art SET source=?, source_path=?, mime_type=?, content_hash=?, width=?, height=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, + source, sourcePath, mimeType, contentHash, width, height, id) + if err != nil { + return nil, err + } + case errors.Is(err, sql.ErrNoRows): + id = idgen.New("ca") + _, err = db.Exec(`INSERT INTO cover_art (id, artist_id, source, source_path, mime_type, content_hash, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, artistID, source, sourcePath, mimeType, contentHash, width, height) + if err != nil { + return nil, err + } + default: + return nil, err + } + if err := SetArtistCoverArt(db, artistID, sql.NullString{String: id, Valid: true}, manual); err != nil { + return nil, err + } + return GetCoverArt(db, id) +} + func nullEmptyStr(s string) any { if s == "" { return nil diff --git a/internal/database/library.go b/internal/database/library.go index e4efbbe..67731f7 100644 --- a/internal/database/library.go +++ b/internal/database/library.go @@ -15,6 +15,23 @@ type Artist struct { Name string SortName string MusicBrainzID string + Bio string + CoverArtID sql.NullString + PhotoManual bool +} + +const artistSelect = `SELECT id, name, sort_name, musicbrainz_id, bio, cover_art_id, photo_manual FROM artists` + +func scanArtist(row *sql.Row) (*Artist, error) { + var a Artist + err := row.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &a, nil } // FindOrCreateArtist looks up an artist by (name, musicbrainz_id) and @@ -43,19 +60,11 @@ func FindOrCreateArtist(db *sql.DB, name, sortName, musicBrainzID string) (*Arti } func GetArtist(db *sql.DB, id string) (*Artist, error) { - var a Artist - err := db.QueryRow(`SELECT id, name, sort_name, musicbrainz_id FROM artists WHERE id = ?`, id).Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID) - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotFound - } - if err != nil { - return nil, err - } - return &a, nil + return scanArtist(db.QueryRow(artistSelect+` WHERE id = ?`, id)) } func ListArtists(db *sql.DB) ([]*Artist, error) { - rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id FROM artists a + rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id, a.bio, a.cover_art_id, a.photo_manual FROM artists a JOIN album_artists aa ON aa.artist_id = a.id ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`) if err != nil { @@ -65,7 +74,7 @@ func ListArtists(db *sql.DB) ([]*Artist, error) { var out []*Artist for rows.Next() { var a Artist - if err := rows.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID); err != nil { + if err := rows.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual); err != nil { return nil, err } out = append(out, &a) @@ -73,6 +82,25 @@ func ListArtists(db *sql.DB) ([]*Artist, error) { return out, rows.Err() } +// UpdateArtist overwrites an artist's name, sort name, and bio. It never +// touches the artist's photo (see SetArtistCoverArt). +func UpdateArtist(db *sql.DB, id, name, sortName, bio string) error { + _, err := db.Exec(`UPDATE artists SET name = ?, sort_name = ?, bio = ? WHERE id = ?`, name, sortName, bio, id) + return err +} + +// SetArtistCoverArt links (or unlinks) an artist's cover_art row. +// manual=true records an admin override; manual=false is the auto-fetch +// path, which is guarded to never overwrite an existing manual override. +func SetArtistCoverArt(db *sql.DB, artistID string, coverArtID sql.NullString, manual bool) error { + if manual { + _, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 1 WHERE id = ?`, nullStr(coverArtID), artistID) + return err + } + _, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 0 WHERE id = ? AND photo_manual = 0`, nullStr(coverArtID), artistID) + return err +} + // --- Genres --- type Genre struct { @@ -223,6 +251,14 @@ func scanAlbums(rows *sql.Rows) ([]*Album, error) { return out, rows.Err() } +// UpdateAlbum overwrites an album's editable metadata fields. It never +// touches the album's cover art (see SetAlbumCoverArt). +func UpdateAlbum(db *sql.DB, id, title, sortTitle string, year, originalYear sql.NullInt64, label string) error { + _, err := db.Exec(`UPDATE albums SET title = ?, sort_title = ?, year = ?, original_year = ?, record_label = ? WHERE id = ?`, + title, sortTitle, nullInt(year), nullInt(originalYear), label, id) + return err +} + func SetAlbumCoverArt(db *sql.DB, albumID, coverArtID string) error { _, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID) return err @@ -260,11 +296,12 @@ type Song struct { ReplayGainTrackPeak sql.NullFloat64 ReplayGainAlbumGainDB sql.NullFloat64 ReplayGainAlbumPeak sql.NullFloat64 + MetadataLocked bool } const songSelect = `SELECT id, media_file_id, album_id, title, sort_title, track_number, track_total, disc_number, disc_total, composer, conductor, isrc, bpm, musicbrainz_track_id, musicbrainz_recording_id, - replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak FROM songs` + replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak, metadata_locked FROM songs` func GetSong(db *sql.DB, id string) (*Song, error) { return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id)) @@ -278,7 +315,7 @@ func scanSong(row *sql.Row) (*Song, error) { var s Song err := row.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal, &s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID, - &s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak) + &s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -312,7 +349,7 @@ func scanSongs(rows *sql.Rows) ([]*Song, error) { var s Song if err := rows.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal, &s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID, - &s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak); err != nil { + &s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked); err != nil { return nil, err } out = append(out, &s) @@ -354,14 +391,29 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) { return nil, err } if existing != nil { - _, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?, - composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?, - replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?, - updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, - nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle, - in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID, - nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak), - existing.ID) + if existing.MetadataLocked { + // An admin has hand-edited title/sort_title/track_number/ + // disc_number/composer/conductor for this song; leave those + // alone and only refresh the fields that come straight off + // the file itself and were never exposed for editing. + _, err = db.Exec(`UPDATE songs SET album_id=?, track_total=?, disc_total=?, disc_subtitle=?, + comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?, + replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?, + updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, + nullStr(in.AlbumID), nullInt(in.TrackTotal), nullInt(in.DiscTotal), in.DiscSubtitle, + in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID, + nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak), + existing.ID) + } else { + _, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?, + composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?, + replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?, + updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, + nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle, + in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID, + nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak), + existing.ID) + } if err != nil { return nil, err } @@ -382,6 +434,15 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) { return GetSong(db, id) } +// UpdateSongMetadata overwrites a song's editable fields. locked=true +// sets metadata_locked so a later library rescan (UpsertSong) leaves +// these fields alone instead of overwriting them from the file's tags. +func UpdateSongMetadata(db *sql.DB, id, title, sortTitle string, trackNumber, discNumber sql.NullInt64, composer, conductor string, locked bool) error { + _, err := db.Exec(`UPDATE songs SET title=?, sort_title=?, track_number=?, disc_number=?, composer=?, conductor=?, metadata_locked=? WHERE id=?`, + title, sortTitle, nullInt(trackNumber), nullInt(discNumber), composer, conductor, locked, id) + return err +} + func ClearSongArtists(db *sql.DB, songID string) error { _, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID) return err diff --git a/internal/database/subsonic_helpers.go b/internal/database/subsonic_helpers.go index 5ec189d..efaac49 100644 --- a/internal/database/subsonic_helpers.go +++ b/internal/database/subsonic_helpers.go @@ -111,7 +111,7 @@ func ListGenreStats(db *sql.DB) ([]GenreStat, error) { func SongsByGenre(db *sql.DB, genreName string, limit, offset int) ([]*Song, error) { rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total, s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id, - s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak + s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked FROM songs s JOIN song_genres sg ON sg.song_id = s.id JOIN genres g ON g.id = sg.genre_id diff --git a/internal/database/subsonic_helpers2.go b/internal/database/subsonic_helpers2.go index b4afdf5..ddf29e0 100644 --- a/internal/database/subsonic_helpers2.go +++ b/internal/database/subsonic_helpers2.go @@ -37,7 +37,7 @@ func FindArtistByName(db *sql.DB, name string) (*Artist, error) { func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) { rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total, s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id, - s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak + s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked FROM songs s JOIN song_artists sa ON sa.song_id = s.id LEFT JOIN (SELECT song_id, COUNT(*) AS plays FROM playback_history GROUP BY song_id) ph ON ph.song_id = s.id @@ -58,7 +58,7 @@ func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) { func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, error) { rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total, s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id, - s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak + s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked FROM songs s JOIN song_genres sg ON sg.song_id = s.id WHERE sg.genre_id IN (SELECT genre_id FROM song_genres WHERE song_id = ?) @@ -78,7 +78,7 @@ func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, err func SimilarSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) { rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total, s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id, - s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak + s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked FROM songs s JOIN song_genres sg ON sg.song_id = s.id WHERE sg.genre_id IN ( diff --git a/internal/podcasts/podcasts.go b/internal/podcasts/podcasts.go index 8bbb614..74ef335 100644 --- a/internal/podcasts/podcasts.go +++ b/internal/podcasts/podcasts.go @@ -252,6 +252,30 @@ func (m *Manager) DownloadEpisode(ctx context.Context, episodeID string) error { return database.SetPodcastEpisodeMediaFile(m.DB, episodeID, mf.ID) } +// DownloadChannel downloads every episode in a channel that is not already +// complete or in progress. Downloads are run in feed order and still pass +// through the manager's concurrency limiter. +func (m *Manager) DownloadChannel(ctx context.Context, channelID string) error { + episodes, err := database.ListPodcastEpisodes(m.DB, channelID) + if err != nil { + return err + } + for _, episode := range episodes { + if episode.Status == "completed" || episode.Status == "downloading" { + continue + } + if err := m.DownloadEpisode(ctx, episode.ID); err != nil { + if m.Log != nil { + m.Log.Warn("podcast episode download failed", "episode", episode.ID, "err", err) + } + } + if err := ctx.Err(); err != nil { + return err + } + } + return nil +} + func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string) error { if err := netguard.ValidateURL(sourceURL); err != nil { return err diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go index 550fc28..c04afe6 100644 --- a/internal/web/admin_handlers.go +++ b/internal/web/admin_handlers.go @@ -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) } diff --git a/internal/web/admin_library_handlers.go b/internal/web/admin_library_handlers.go new file mode 100644 index 0000000..8ab43c6 --- /dev/null +++ b/internal/web/admin_library_handlers.go @@ -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 +} diff --git a/internal/web/artist_photo.go b/internal/web/artist_photo.go new file mode 100644 index 0000000..1eaffdb --- /dev/null +++ b/internal/web/artist_photo.go @@ -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" + } +} diff --git a/internal/web/bookmarks_handlers.go b/internal/web/bookmarks_handlers.go index 26c48b9..b638649 100644 --- a/internal/web/bookmarks_handlers.go +++ b/internal/web/bookmarks_handlers.go @@ -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) } diff --git a/internal/web/coverart_handler.go b/internal/web/coverart_handler.go index e8595ad..be7c45e 100644 --- a/internal/web/coverart_handler.go +++ b/internal/web/coverart_handler.go @@ -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 } diff --git a/internal/web/pages.go b/internal/web/pages.go index 0244b7f..d9def28 100644 --- a/internal/web/pages.go +++ b/internal/web/pages.go @@ -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)) } } } diff --git a/internal/web/playlists.go b/internal/web/playlists.go index 8ac8fdd..d30c353 100644 --- a/internal/web/playlists.go +++ b/internal/web/playlists.go @@ -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) } diff --git a/internal/web/podcasts_handlers.go b/internal/web/podcasts_handlers.go index 219e82a..a3cd945 100644 --- a/internal/web/podcasts_handlers.go +++ b/internal/web/podcasts_handlers.go @@ -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) +} diff --git a/internal/web/router.go b/internal/web/router.go index 8196a43..d837ee3 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -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)) diff --git a/internal/web/share_handlers.go b/internal/web/share_handlers.go index 0e474aa..66eca27 100644 --- a/internal/web/share_handlers.go +++ b/internal/web/share_handlers.go @@ -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) diff --git a/internal/web/views.go b/internal/web/views.go index 9c9b074..03a9187 100644 --- a/internal/web/views.go +++ b/internal/web/views.go @@ -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 diff --git a/migrations/0005_artist_photos_and_edits.sql b/migrations/0005_artist_photos_and_edits.sql new file mode 100644 index 0000000..d42fa0b --- /dev/null +++ b/migrations/0005_artist_photos_and_edits.sql @@ -0,0 +1,61 @@ +-- Artist photos (auto-fetched or manually overridden) reuse the +-- existing cover_art table rather than a parallel one, so photo +-- resizing/caching/serving (internal/coverart, /cover-art/{id}) works +-- unchanged for artists. That requires adding an artist_id column and +-- widening the "must belong to something" CHECK, which SQLite can only +-- do by rebuilding the table. +-- +-- albums.cover_art_id and podcast_channels.cover_art_id are declared +-- "ON DELETE SET NULL" against cover_art(id), and SQLite's DROP TABLE +-- performs an implicit DELETE FROM first when foreign_keys=ON — which +-- would fire that SET NULL and silently wipe every album's/channel's +-- chosen cover on upgrade. Back those columns up and restore them +-- after the rebuild. +CREATE TEMP TABLE _cover_art_migration_backup_albums AS + SELECT id, cover_art_id FROM albums WHERE cover_art_id IS NOT NULL; +CREATE TEMP TABLE _cover_art_migration_backup_podcasts AS + SELECT id, cover_art_id FROM podcast_channels WHERE cover_art_id IS NOT NULL; + +CREATE TABLE cover_art_new ( + id TEXT PRIMARY KEY, + album_id TEXT REFERENCES albums(id) ON DELETE CASCADE, + media_file_id TEXT REFERENCES media_files(id) ON DELETE CASCADE, + artist_id TEXT REFERENCES artists(id) ON DELETE CASCADE, + source TEXT NOT NULL CHECK (source IN ('embedded','folder','external')), + source_path TEXT NOT NULL DEFAULT '', + mime_type TEXT NOT NULL DEFAULT '', + content_hash TEXT NOT NULL DEFAULT '', + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + CHECK ((album_id IS NOT NULL) OR (media_file_id IS NOT NULL) OR (artist_id IS NOT NULL)) +); + +INSERT INTO cover_art_new (id, album_id, media_file_id, artist_id, source, source_path, mime_type, content_hash, width, height, updated_at) +SELECT id, album_id, media_file_id, NULL, source, source_path, mime_type, content_hash, width, height, updated_at FROM cover_art; + +DROP TABLE cover_art; +ALTER TABLE cover_art_new RENAME TO cover_art; + +CREATE INDEX idx_cover_art_album ON cover_art(album_id); +CREATE INDEX idx_cover_art_media_file ON cover_art(media_file_id); +CREATE INDEX idx_cover_art_artist ON cover_art(artist_id); + +UPDATE albums SET cover_art_id = ( + SELECT cover_art_id FROM _cover_art_migration_backup_albums WHERE _cover_art_migration_backup_albums.id = albums.id +) WHERE id IN (SELECT id FROM _cover_art_migration_backup_albums); +UPDATE podcast_channels SET cover_art_id = ( + SELECT cover_art_id FROM _cover_art_migration_backup_podcasts WHERE _cover_art_migration_backup_podcasts.id = podcast_channels.id +) WHERE id IN (SELECT id FROM _cover_art_migration_backup_podcasts); + +DROP TABLE _cover_art_migration_backup_albums; +DROP TABLE _cover_art_migration_backup_podcasts; + +-- Artist bio + photo (auto-fetched or admin-overridden). +ALTER TABLE artists ADD COLUMN bio TEXT NOT NULL DEFAULT ''; +ALTER TABLE artists ADD COLUMN cover_art_id TEXT REFERENCES cover_art(id); +ALTER TABLE artists ADD COLUMN photo_manual INTEGER NOT NULL DEFAULT 0; + +-- Lets an admin edit a song's title/track/disc/composer/conductor +-- without a later library rescan silently overwriting the edit. +ALTER TABLE songs ADD COLUMN metadata_locked INTEGER NOT NULL DEFAULT 0; diff --git a/web/static/css/app.css b/web/static/css/app.css index ea2bdb4..85e06de 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -178,6 +178,21 @@ body { text-align: right; } +.vs-song-art-cell { + width: 48px; + padding-left: 0 !important; + padding-right: 0 !important; +} + +.vs-song-art { + display: block; + width: 40px; + height: 40px; + border-radius: 0.25rem; + object-fit: cover; + background: var(--bs-secondary-bg); +} + /* --- Player bar --- */ .vs-player { @@ -352,3 +367,9 @@ body { display: none; } } +/* Inline page transitions keep the persistent player mounted. */ +.vs-main[aria-busy="true"] { + opacity: .65; + transition: opacity 120ms ease; + pointer-events: none; +} diff --git a/web/static/js/app.js b/web/static/js/app.js index 234162c..7f531b8 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -474,7 +474,134 @@ form.appendChild(input); }); document.body.appendChild(form); - form.submit(); + form.requestSubmit(); + }); + } + + // --- Inline navigation --- + // Fetch server-rendered pages and replace only the main content area. The + // audio element and player live outside that area, so playback continues + // uninterrupted while the URL, title, and active sidebar item still track + // the current page normally. + function initInlineNavigation() { + var pending = null; + var currentURL = location.href; + history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL); + + function isLocalHTTP(url) { + return (url.protocol === "http:" || url.protocol === "https:") && url.origin === location.origin; + } + + function shouldHandleLink(link, ev) { + if (!link || ev.defaultPrevented || ev.button !== 0) return false; + if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return false; + if (link.target || link.hasAttribute("download") || link.dataset.noInline != null) return false; + var url = new URL(link.href, location.href); + if (!isLocalHTTP(url)) return false; + if (url.pathname.indexOf("/static/") === 0 || url.pathname.indexOf("/stream/") === 0 || + url.pathname.indexOf("/cover-art/") === 0 || url.pathname.indexOf("/share/") === 0 || + /\/export$/.test(url.pathname)) return false; + // Let the browser handle an in-page fragment without a network request. + if (url.pathname === location.pathname && url.search === location.search && url.hash) return false; + return true; + } + + function setBusy(busy) { + var main = document.querySelector(".vs-main"); + if (!main) return; + if (busy) main.setAttribute("aria-busy", "true"); + else main.removeAttribute("aria-busy"); + } + + function applyPage(html, responseURL, push, restoreScroll) { + var parsed = new DOMParser().parseFromString(html, "text/html"); + var nextMain = parsed.querySelector(".vs-main"); + var oldMain = document.querySelector(".vs-main"); + // Login, setup, shared, and error documents are intentionally full-page. + if (!nextMain || !oldMain) return false; + + oldMain.replaceWith(nextMain); + document.title = parsed.title || document.title; + var tokenScript = parsed.querySelector("script[data-vs-csrf]"); + if (tokenScript) window.vsCSRFToken = tokenScript.dataset.vsCsrf; + + var activeHref = parsed.querySelector(".vs-sidebar .nav-link.active"); + document.querySelectorAll(".vs-sidebar .nav-link").forEach(function (link) { + link.classList.toggle("active", !!activeHref && link.getAttribute("href") === activeHref.getAttribute("href")); + }); + + currentURL = responseURL; + if (push) history.pushState({ vsInline: true, scrollY: 0 }, "", responseURL); + var top = typeof restoreScroll === "number" ? restoreScroll : 0; + window.scrollTo(0, top); + var autofocus = nextMain.querySelector("[autofocus]"); + if (autofocus) autofocus.focus({ preventScroll: true }); + return true; + } + + function navigate(url, options) { + options = options || {}; + if (pending) pending.abort(); + var controller = new AbortController(); + pending = controller; + setBusy(true); + var request = { + method: options.method || "GET", + body: options.body || null, + signal: controller.signal, + headers: { "X-VibeSonic-Inline": "1" }, + }; + return fetch(url, request).then(function (response) { + if (!response.ok || !(response.headers.get("Content-Type") || "").includes("text/html")) { + throw new Error("not an inline page"); + } + return response.text().then(function (html) { + if (!applyPage(html, response.url, options.push !== false, options.restoreScroll)) { + location.assign(response.url); + } + }); + }).catch(function (err) { + if (err.name !== "AbortError") { + if ((options.method || "GET") === "GET") location.assign(url); + else location.reload(); + } + }).finally(function () { + if (pending === controller) { + pending = null; + setBusy(false); + } + }); + } + + document.addEventListener("click", function (ev) { + var link = ev.target.closest("a[href]"); + if (!shouldHandleLink(link, ev)) return; + ev.preventDefault(); + history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL); + navigate(link.href); + }); + + document.addEventListener("submit", function (ev) { + var form = ev.target; + if (ev.defaultPrevented || !(form instanceof HTMLFormElement) || form.dataset.noInline != null) return; + var url = new URL(form.action || location.href, location.href); + if (!isLocalHTTP(url) || url.pathname === "/logout") return; + ev.preventDefault(); + var data = new FormData(form); + var method = (form.method || "GET").toUpperCase(); + if (method === "GET") { + url.search = new URLSearchParams(data).toString(); + navigate(url.href); + } else { + navigate(url.href, { method: method, body: data }); + } + }); + + window.addEventListener("popstate", function (ev) { + navigate(location.href, { + push: false, + restoreScroll: ev.state && typeof ev.state.scrollY === "number" ? ev.state.scrollY : 0, + }); }); } @@ -483,5 +610,6 @@ var player = initPlayer(); initKeyboardShortcuts(player); initStarButtons(); + initInlineNavigation(); }); })(); diff --git a/web/templates/admin_album_edit.html b/web/templates/admin_album_edit.html new file mode 100644 index 0000000..aa08ada --- /dev/null +++ b/web/templates/admin_album_edit.html @@ -0,0 +1,51 @@ +{{define "admin_album_edit"}}{{template "header" .}} +
+
+

Edit album

+
+ {{template "csrf_field" .CSRFToken}} +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+

Cover art

+ {{if .Data.CoverArtID.Valid}} + + {{end}} +
+ + +
+
+ + +
+ +
+ + Cancel +
+
+
+
+{{template "footer" .}}{{end}} diff --git a/web/templates/admin_artist_edit.html b/web/templates/admin_artist_edit.html new file mode 100644 index 0000000..ac5f6b5 --- /dev/null +++ b/web/templates/admin_artist_edit.html @@ -0,0 +1,50 @@ +{{define "admin_artist_edit"}}{{template "header" .}} +
+
+

Edit artist

+
+ {{template "csrf_field" .CSRFToken}} +
+ + +
+
+ + +
+
+ + +
+ +
+

Photo

+ {{if .Data.CoverArtID.Valid}} + + {{end}} + {{if .Data.PhotoManual}} +

This photo was set manually and will not be replaced by auto-fetch.

+
+ + +
+ {{else}} +

No custom photo set{{if not .Data.MusicBrainzID}} (this artist has no MusicBrainz ID, so auto-fetch cannot run){{end}}.

+ {{end}} +
+ + +
+
+ + +
+ +
+ + Cancel +
+
+
+
+{{template "footer" .}}{{end}} diff --git a/web/templates/admin_podcasts.html b/web/templates/admin_podcasts.html index 5d5e122..f2d1cf7 100644 --- a/web/templates/admin_podcasts.html +++ b/web/templates/admin_podcasts.html @@ -22,6 +22,13 @@ {{if .Channel.ErrorMessage}}
{{.Channel.ErrorMessage}}
{{end}}
+ {{if .Episodes}} +
+ {{template "csrf_field" $.CSRFToken}} + + +
+ {{end}}
{{template "csrf_field" $.CSRFToken}} diff --git a/web/templates/admin_settings.html b/web/templates/admin_settings.html index f698c4a..316183d 100644 --- a/web/templates/admin_settings.html +++ b/web/templates/admin_settings.html @@ -21,6 +21,43 @@
+
+
+

Artist photos

+ {{if .Data.ArtistPhotosEnabled}} +

+ Fetch a photo (fanart.tv when an artist has a tagged MusicBrainz ID and a key is configured, otherwise a direct name search against Wikidata/Wikimedia Commons) for every artist with no manually-set photo. This never calls MusicBrainz itself, so it isn't affected by its rate limits — matching by name alone is less precise, though. This bypasses any previously-cached "not found" result, so it's a good way to retry after fixing a config or network issue. +

+ {{else}} +

+ Automatic background fetching is disabled (VIBESONIC_ENABLE_ARTIST_PHOTOS=false), but this button still runs a one-off fetch — useful for checking why photos aren't showing up. +

+ {{end}} + + {{template "csrf_field" .CSRFToken}} + + + {{if .Data.ArtistPhotoResults}} + + + + {{range .Data.ArtistPhotoResults}} + + + + + + {{end}} + +
ArtistStatusError
{{.ArtistName}} + {{if .Error}}{{.Status}} + {{else if eq .Status "fetched"}}{{.Status}} + {{else}}{{.Status}}{{end}} + {{.Error}}
+ {{end}} +
+
+

External authentication

diff --git a/web/templates/admin_song_edit.html b/web/templates/admin_song_edit.html new file mode 100644 index 0000000..ee94a03 --- /dev/null +++ b/web/templates/admin_song_edit.html @@ -0,0 +1,46 @@ +{{define "admin_song_edit"}}{{template "header" .}} +
+
+

Edit song

+
+ {{template "csrf_field" .CSRFToken}} +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+
+{{template "footer" .}}{{end}} diff --git a/web/templates/album.html b/web/templates/album.html index 9f287e6..676c417 100644 --- a/web/templates/album.html +++ b/web/templates/album.html @@ -11,6 +11,9 @@ {{template "csrf_field" .CSRFToken}} + {{if and .User .User.IsAdmin}} + Edit album + {{end}}
{{template "song_rows" .Data.Songs}} diff --git a/web/templates/artist.html b/web/templates/artist.html index 6ef4eec..4e316a0 100644 --- a/web/templates/artist.html +++ b/web/templates/artist.html @@ -1,4 +1,14 @@ {{define "artist"}}{{template "header" .}} +
+ +
+

{{.Data.Artist.Name}}

+ {{if .Data.Artist.Bio}}

{{.Data.Artist.Bio}}

{{end}} + {{if and .User .User.IsAdmin}} + Edit artist + {{end}} +
+
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
diff --git a/web/templates/partials/cards.html b/web/templates/partials/cards.html index ba7f34a..64a5440 100644 --- a/web/templates/partials/cards.html +++ b/web/templates/partials/cards.html @@ -8,7 +8,7 @@ {{define "artist_card"}} - +
{{.Name}}
{{end}} @@ -26,11 +26,17 @@ data-cover="{{$s.CoverArtID}}" data-starred="{{if $s.Starred}}1{{else}}0{{end}}"> {{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}} + + +
{{$s.Title}}
{{if $s.AlbumTitle}}
{{$s.ArtistName}} — {{$s.AlbumTitle}}
{{else}}
{{$s.ArtistName}}
{{end}} - {{fmtDuration $s.DurationSeconds}} + + {{fmtDuration $s.DurationSeconds}} + {{if $s.IsAdmin}}{{end}} + {{end}} diff --git a/web/templates/partials/header.html b/web/templates/partials/header.html index 3682b80..86866f5 100644 --- a/web/templates/partials/header.html +++ b/web/templates/partials/header.html @@ -19,7 +19,7 @@ - +
diff --git a/web/templates/podcasts.html b/web/templates/podcasts.html index 95321d6..255c4f5 100644 --- a/web/templates/podcasts.html +++ b/web/templates/podcasts.html @@ -3,7 +3,15 @@ {{range .Data}}
-

{{.Channel.Title}}

+
+

{{.Channel.Title}}

+ {{if .Episodes}} +
+ {{template "csrf_field" $.CSRFToken}} + +
+ {{end}} +

{{.Channel.Description}}