This commit is contained in:
2026-08-23 14:13:18 -05:00
parent 789bf91eed
commit 866f243f1b
37 changed files with 1761 additions and 58 deletions
+3 -1
View File
@@ -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_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_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_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_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). | | `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 ## External authentication
+29 -2
View File
@@ -21,8 +21,9 @@ authentication model in detail.
comparison itself uses a constant-time compare comparison itself uses a constant-time compare
(`crypto/subtle.ConstantTimeCompare`) to avoid a timing side-channel. (`crypto/subtle.ConstantTimeCompare`) to avoid a timing side-channel.
- **API keys** are stored and looked up by hash, never in plaintext. - **API keys** are stored and looked up by hash, never in plaintext.
- **Sessions** are random opaque tokens in an HTTP-only, `Secure` - **Sessions** are random opaque tokens in an HTTP-only cookie. Set
(configurable via `VIBESONIC_COOKIE_SECURE`, on by default) cookie. `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, - **External (trusted-proxy) authentication** is off by default and,
when enabled, trusts an identity-asserting header only from when enabled, trusts an identity-asserting header only from
configured source IPs verified against the real TCP peer address — 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 40 megapixels. This prevents a small, highly-compressed image file from
forcing an out-of-proportion memory allocation ("decompression bomb"). 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 ## Transcoding
`ffmpeg`/`ffprobe` are invoked with `exec.CommandContext` and an `ffmpeg`/`ffprobe` are invoked with `exec.CommandContext` and an
+285
View File
@@ -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
}
+213
View File
@@ -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)
}
}
+18 -1
View File
@@ -33,6 +33,17 @@ type Config struct {
// to the outside world. // to the outside world.
EnableExternalMetadata bool 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 // PodcastsAllowPrivateNetworks disables SSRF protection on podcast
// feed/enclosure fetching, allowing feeds hosted on loopback/LAN/ // feed/enclosure fetching, allowing feeds hosted on loopback/LAN/
// link-local addresses. Off by default: podcast URLs are supplied // link-local addresses. Off by default: podcast URLs are supplied
@@ -68,10 +79,16 @@ func Load() (Config, error) {
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"), FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"), MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"),
SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"), 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), 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), PodcastsAllowPrivateNetworks: envBool("VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS", false),
} }
cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db")) cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db"))
+14
View File
@@ -25,6 +25,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.DBPath != "/var/lib/vibesonic/vibesonic.db" { if cfg.DBPath != "/var/lib/vibesonic/vibesonic.db" {
t.Errorf("DBPath = %q, want default under DataDir", cfg.DBPath) 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 { if cfg.ExtAuth.Enabled {
t.Errorf("ExtAuth.Enabled = true, want false by default") 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) { func TestLoadMusicDirs(t *testing.T) {
withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "}) withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "})
cfg, err := Load() cfg, err := Load()
+5 -1
View File
@@ -77,7 +77,11 @@ func (s *Store) ExtractEmbedded(ctx context.Context, mediaPath string, streamInd
"-map", fmt.Sprintf("0:%d", streamIndex), "-map", fmt.Sprintf("0:%d", streamIndex),
"-frames:v", "1", "-frames:v", "1",
"-c:v", "copy", "-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 { if out, err := cmd.CombinedOutput(); err != nil {
os.Remove(tmp.Name()) os.Remove(tmp.Name())
+28
View File
@@ -1,6 +1,7 @@
package coverart package coverart
import ( import (
"context"
"image" "image"
"image/color" "image/color"
"image/jpeg" "image/jpeg"
@@ -9,6 +10,33 @@ import (
"testing" "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) { func writeTestJPEG(t *testing.T, path string, w, h int) {
t.Helper() t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h)) img := image.NewRGBA(image.Rect(0, 0, w, h))
+33 -2
View File
@@ -11,6 +11,7 @@ type CoverArt struct {
ID string ID string
AlbumID sql.NullString AlbumID sql.NullString
MediaFileID sql.NullString MediaFileID sql.NullString
ArtistID sql.NullString
Source string Source string
SourcePath string SourcePath string
MimeType string MimeType string
@@ -19,7 +20,7 @@ type CoverArt struct {
Height int 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) { func GetCoverArt(db *sql.DB, id string) (*CoverArt, error) {
return scanCoverArt(db.QueryRow(coverArtSelect+` WHERE id = ?`, id)) 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) { func scanCoverArt(row *sql.Row) (*CoverArt, error) {
var c CoverArt 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -71,6 +72,36 @@ func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mediaFileID, m
return GetCoverArt(db, id) 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 { func nullEmptyStr(s string) any {
if s == "" { if s == "" {
return nil return nil
+83 -22
View File
@@ -15,6 +15,23 @@ type Artist struct {
Name string Name string
SortName string SortName string
MusicBrainzID 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 // 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) { func GetArtist(db *sql.DB, id string) (*Artist, error) {
var a Artist return scanArtist(db.QueryRow(artistSelect+` WHERE id = ?`, id))
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
} }
func ListArtists(db *sql.DB) ([]*Artist, error) { 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 JOIN album_artists aa ON aa.artist_id = a.id
ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`) ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`)
if err != nil { if err != nil {
@@ -65,7 +74,7 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
var out []*Artist var out []*Artist
for rows.Next() { for rows.Next() {
var a Artist 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 return nil, err
} }
out = append(out, &a) out = append(out, &a)
@@ -73,6 +82,25 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
return out, rows.Err() 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 --- // --- Genres ---
type Genre struct { type Genre struct {
@@ -223,6 +251,14 @@ func scanAlbums(rows *sql.Rows) ([]*Album, error) {
return out, rows.Err() 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 { func SetAlbumCoverArt(db *sql.DB, albumID, coverArtID string) error {
_, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID) _, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID)
return err return err
@@ -260,11 +296,12 @@ type Song struct {
ReplayGainTrackPeak sql.NullFloat64 ReplayGainTrackPeak sql.NullFloat64
ReplayGainAlbumGainDB sql.NullFloat64 ReplayGainAlbumGainDB sql.NullFloat64
ReplayGainAlbumPeak 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, 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, 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) { func GetSong(db *sql.DB, id string) (*Song, error) {
return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id)) return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id))
@@ -278,7 +315,7 @@ func scanSong(row *sql.Row) (*Song, error) {
var s Song var s Song
err := row.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal, 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.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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -312,7 +349,7 @@ func scanSongs(rows *sql.Rows) ([]*Song, error) {
var s Song 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, 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.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 return nil, err
} }
out = append(out, &s) out = append(out, &s)
@@ -354,14 +391,29 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
return nil, err return nil, err
} }
if existing != nil { if existing != nil {
_, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?, if existing.MetadataLocked {
composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?, // An admin has hand-edited title/sort_title/track_number/
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?, // disc_number/composer/conductor for this song; leave those
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, // alone and only refresh the fields that come straight off
nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle, // the file itself and were never exposed for editing.
in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID, _, err = db.Exec(`UPDATE songs SET album_id=?, track_total=?, disc_total=?, disc_subtitle=?,
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak), comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
existing.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 { if err != nil {
return nil, err return nil, err
} }
@@ -382,6 +434,15 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
return GetSong(db, id) 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 { func ClearSongArtists(db *sql.DB, songID string) error {
_, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID) _, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID)
return err return err
+1 -1
View File
@@ -111,7 +111,7 @@ func ListGenreStats(db *sql.DB) ([]GenreStat, error) {
func SongsByGenre(db *sql.DB, genreName string, limit, offset int) ([]*Song, 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, 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.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 FROM songs s
JOIN song_genres sg ON sg.song_id = s.id JOIN song_genres sg ON sg.song_id = s.id
JOIN genres g ON g.id = sg.genre_id JOIN genres g ON g.id = sg.genre_id
+3 -3
View File
@@ -37,7 +37,7 @@ func FindArtistByName(db *sql.DB, name string) (*Artist, error) {
func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, 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, 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.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 FROM songs s
JOIN song_artists sa ON sa.song_id = s.id 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 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) { 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, 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.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 FROM songs s
JOIN song_genres sg ON sg.song_id = s.id JOIN song_genres sg ON sg.song_id = s.id
WHERE sg.genre_id IN (SELECT genre_id FROM song_genres WHERE song_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) { 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, 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.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 FROM songs s
JOIN song_genres sg ON sg.song_id = s.id JOIN song_genres sg ON sg.song_id = s.id
WHERE sg.genre_id IN ( WHERE sg.genre_id IN (
+24
View File
@@ -252,6 +252,30 @@ func (m *Manager) DownloadEpisode(ctx context.Context, episodeID string) error {
return database.SetPodcastEpisodeMediaFile(m.DB, episodeID, mf.ID) 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 { func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string) error {
if err := netguard.ValidateURL(sourceURL); err != nil { if err := netguard.ValidateURL(sourceURL); err != nil {
return err return err
+50 -4
View File
@@ -2,6 +2,7 @@ package web
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
@@ -283,10 +284,10 @@ func (h *Web) handleAdminScanner(w http.ResponseWriter, r *http.Request) {
// --- System settings --- // --- System settings ---
func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) { 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 := "" trusted := ""
for i, p := range h.Config.ExtAuth.TrustedSources { for i, p := range h.Config.ExtAuth.TrustedSources {
if i > 0 { if i > 0 {
@@ -301,6 +302,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
ExtAuthEnabled bool ExtAuthEnabled bool
ExtAuthTrustedSources string ExtAuthTrustedSources string
ExtAuthHeader string ExtAuthHeader string
ArtistPhotosEnabled bool
ArtistPhotoResults []ArtistPhotoOutcome
}{ }{
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()), ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath), 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, ExtAuthEnabled: h.Config.ExtAuth.Enabled,
ExtAuthTrustedSources: trusted, ExtAuthTrustedSources: trusted,
ExtAuthHeader: h.Config.ExtAuth.UserHeader, 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") interval := r.FormValue("scan_interval")
if _, err := time.ParseDuration(interval); err != nil { 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 return
} }
_ = database.SetSetting(h.DB, "scan_interval", interval) _ = database.SetSetting(h.DB, "scan_interval", interval)
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path")) _ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
_ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_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)
} }
+195
View File
@@ -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
}
+278
View File
@@ -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"
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ func (h *Web) handleBookmarks(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
continue 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) h.page(w, r, "bookmarks", "bookmarks", "Bookmarks", views)
} }
+4 -1
View File
@@ -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 // row, extracting embedded artwork to a temp file on demand. isTemp
// tells the caller whether it must remove the returned path afterward. // 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) { 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 return art.SourcePath, art.ContentHash, false, nil
} }
+12 -5
View File
@@ -75,6 +75,7 @@ func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
h.maybeFetchArtistPhoto(artist)
albums, err := database.ListAlbumsByArtist(h.DB, id) albums, err := database.ListAlbumsByArtist(h.DB, id)
if err != nil { if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError) 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)) views = append(views, toAlbumView(h.DB, a))
} }
h.page(w, r, "artist", "artists", artist.Name, struct { h.page(w, r, "artist", "artists", artist.Name, struct {
Artist *database.Artist Artist ArtistView
Albums []AlbumView Albums []AlbumView
}{artist, views}) }{toArtistView(artist), views})
} }
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) { 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 return
} }
userID := "" userID := ""
isAdmin := false
if user != nil { if user != nil {
userID = user.ID userID = user.ID
isAdmin = user.IsAdmin
} }
var rows []SongRow var rows []SongRow
for _, s := range songs { 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 starred := false
if user != nil { if user != nil {
@@ -157,12 +160,14 @@ func (h *Web) handleSongs(w http.ResponseWriter, r *http.Request) {
songs = songs[:pageSize] songs = songs[:pageSize]
} }
userID := "" userID := ""
isAdmin := false
if user != nil { if user != nil {
userID = user.ID userID = user.ID
isAdmin = user.IsAdmin
} }
var rows []SongRow var rows []SongRow
for _, s := range songs { 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 { h.page(w, r, "songs", "songs", "Songs", struct {
Songs []SongRow Songs []SongRow
@@ -219,14 +224,16 @@ func (h *Web) handleSearch(w http.ResponseWriter, r *http.Request) {
if songIDRows != nil { if songIDRows != nil {
defer songIDRows.Close() defer songIDRows.Close()
userID := "" userID := ""
isAdmin := false
if user != nil { if user != nil {
userID = user.ID userID = user.ID
isAdmin = user.IsAdmin
} }
for songIDRows.Next() { for songIDRows.Next() {
var id string var id string
if songIDRows.Scan(&id) == nil { if songIDRows.Scan(&id) == nil {
if song, err := database.GetSong(h.DB, id); err == 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))
} }
} }
} }
+3 -3
View File
@@ -58,7 +58,7 @@ func (h *Web) handlePlaylist(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
continue 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 { h.page(w, r, "playlist", "playlists", playlist.Name, struct {
Playlist *database.Playlist Playlist *database.Playlist
@@ -109,7 +109,7 @@ func (h *Web) handleFavorites(w http.ResponseWriter, r *http.Request) {
var songs []SongRow var songs []SongRow
for _, id := range songIDs { for _, id := range songIDs {
if s, err := database.GetSong(h.DB, id); err == nil { 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 { if err != nil {
continue 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) h.page(w, r, "recent", "recent", "Recently Played", rows)
} }
+22
View File
@@ -34,3 +34,25 @@ func (h *Web) handlePodcastDownload(w http.ResponseWriter, r *http.Request) {
go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }() go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }()
http.Redirect(w, r, "/podcasts", http.StatusFound) 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)
}
+9
View File
@@ -53,6 +53,7 @@ func (h *Web) NewRouter() http.Handler {
mux.HandleFunc("GET /podcasts", h.requireUser(h.handlePodcasts)) mux.HandleFunc("GET /podcasts", h.requireUser(h.handlePodcasts))
mux.HandleFunc("POST /podcasts/{id}/download", h.requireUser(h.handlePodcastDownload)) 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("GET /radio", h.requireUser(h.handleRadio))
mux.HandleFunc("POST /radio", h.requireAdmin(h.handleRadioCreate)) 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/scanner", h.requireAdmin(h.handleAdminScanner))
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings)) mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate)) 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("GET /admin/podcasts", h.requireAdmin(h.handleAdminPodcasts))
mux.HandleFunc("POST /admin/podcasts", h.requireAdmin(h.handleAdminPodcastCreate)) 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}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload))
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete)) 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("GET /admin/api-keys", h.requireAdmin(h.handleAdminAPIKeys))
mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke)) mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke))
+1 -1
View File
@@ -31,7 +31,7 @@ func (h *Web) handleSharePage(w http.ResponseWriter, r *http.Request) {
if serr != nil { if serr != nil {
continue 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) owner, _ := database.GetUserByID(h.DB, share.OwnerID)
+14 -5
View File
@@ -10,8 +10,10 @@ import (
// scanning details (sql.Null*) out of the .html files. // scanning details (sql.Null*) out of the .html files.
type ArtistView struct { type ArtistView struct {
ID string ID string
Name string Name string
Bio string
CoverArtID string
} }
type AlbumView struct { type AlbumView struct {
@@ -32,10 +34,15 @@ type SongRow struct {
TrackNumber int64 TrackNumber int64
DurationSeconds float64 DurationSeconds float64
Starred bool Starred bool
IsAdmin bool
} }
func toArtistView(a *database.Artist) ArtistView { 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 { 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 // 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 // be nil for albumless tracks) and the current user (for the starred
// flag; pass "" userID to skip that lookup). // flag; pass "" userID to skip that lookup, and isAdmin=false to hide
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string) SongRow { // the row's "Edit" link).
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string, isAdmin bool) SongRow {
row := SongRow{ row := SongRow{
ID: s.ID, ID: s.ID,
Title: s.Title, Title: s.Title,
DurationSeconds: 0, DurationSeconds: 0,
IsAdmin: isAdmin,
} }
if s.TrackNumber.Valid { if s.TrackNumber.Valid {
row.TrackNumber = s.TrackNumber.Int64 row.TrackNumber = s.TrackNumber.Int64
@@ -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;
+21
View File
@@ -178,6 +178,21 @@ body {
text-align: right; 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 --- */ /* --- Player bar --- */
.vs-player { .vs-player {
@@ -352,3 +367,9 @@ body {
display: none; display: none;
} }
} }
/* Inline page transitions keep the persistent player mounted. */
.vs-main[aria-busy="true"] {
opacity: .65;
transition: opacity 120ms ease;
pointer-events: none;
}
+129 -1
View File
@@ -474,7 +474,134 @@
form.appendChild(input); form.appendChild(input);
}); });
document.body.appendChild(form); 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(); var player = initPlayer();
initKeyboardShortcuts(player); initKeyboardShortcuts(player);
initStarButtons(); initStarButtons();
initInlineNavigation();
}); });
})(); })();
+51
View File
@@ -0,0 +1,51 @@
{{define "admin_album_edit"}}{{template "header" .}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">Edit album</h2>
<form method="post" action="/admin/albums/{{.Data.ID}}/edit" enctype="multipart/form-data">
{{template "csrf_field" .CSRFToken}}
<div class="mb-2">
<label class="form-label small">Title</label>
<input type="text" name="title" class="form-control form-control-sm" value="{{.Data.Title}}" required>
</div>
<div class="mb-2">
<label class="form-label small">Sort title</label>
<input type="text" name="sort_title" class="form-control form-control-sm" value="{{.Data.SortTitle}}">
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small">Year</label>
<input type="number" name="year" class="form-control form-control-sm" value="{{if .Data.Year.Valid}}{{.Data.Year.Int64}}{{end}}">
</div>
<div class="col-6">
<label class="form-label small">Original year</label>
<input type="number" name="original_year" class="form-control form-control-sm" value="{{if .Data.OriginalYear.Valid}}{{.Data.OriginalYear.Int64}}{{end}}">
</div>
</div>
<div class="mb-3">
<label class="form-label small">Record label</label>
<input type="text" name="record_label" class="form-control form-control-sm" value="{{.Data.RecordLabel}}">
</div>
<hr>
<h3 class="h6">Cover art</h3>
{{if .Data.CoverArtID.Valid}}
<img src="/cover-art/{{.Data.CoverArtID.String}}?size=150" style="width:120px;" class="mb-2" alt="">
{{end}}
<div class="mb-2">
<label class="form-label small">Upload a cover</label>
<input type="file" name="cover_file" class="form-control form-control-sm" accept="image/*">
</div>
<div class="mb-3">
<label class="form-label small">...or set a cover URL</label>
<input type="url" name="cover_url" class="form-control form-control-sm" placeholder="https://example.com/cover.jpg">
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<a href="/albums/{{.Data.ID}}" class="btn btn-sm btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{template "footer" .}}{{end}}
+50
View File
@@ -0,0 +1,50 @@
{{define "admin_artist_edit"}}{{template "header" .}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">Edit artist</h2>
<form method="post" action="/admin/artists/{{.Data.ID}}/edit" enctype="multipart/form-data">
{{template "csrf_field" .CSRFToken}}
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" name="name" class="form-control form-control-sm" value="{{.Data.Name}}" required>
</div>
<div class="mb-2">
<label class="form-label small">Sort name</label>
<input type="text" name="sort_name" class="form-control form-control-sm" value="{{.Data.SortName}}">
</div>
<div class="mb-3">
<label class="form-label small">Biography</label>
<textarea name="bio" class="form-control form-control-sm" rows="4">{{.Data.Bio}}</textarea>
</div>
<hr>
<h3 class="h6">Photo</h3>
{{if .Data.CoverArtID.Valid}}
<img src="/cover-art/{{.Data.CoverArtID.String}}?size=150" style="width:100px;height:100px;border-radius:50%;object-fit:cover;" class="mb-2" alt="">
{{end}}
{{if .Data.PhotoManual}}
<p class="small text-body-secondary">This photo was set manually and will not be replaced by auto-fetch.</p>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="remove_photo" value="1" id="remove_photo">
<label class="form-check-label" for="remove_photo">Remove custom photo (revert to auto-fetch)</label>
</div>
{{else}}
<p class="small text-body-secondary">No custom photo set{{if not .Data.MusicBrainzID}} (this artist has no MusicBrainz ID, so auto-fetch cannot run){{end}}.</p>
{{end}}
<div class="mb-2">
<label class="form-label small">Upload a photo</label>
<input type="file" name="photo_file" class="form-control form-control-sm" accept="image/*">
</div>
<div class="mb-3">
<label class="form-label small">...or set a photo URL</label>
<input type="url" name="photo_url" class="form-control form-control-sm" placeholder="https://example.com/photo.jpg">
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<a href="/artists/{{.Data.ID}}" class="btn btn-sm btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{template "footer" .}}{{end}}
+7
View File
@@ -22,6 +22,13 @@
{{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}} {{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}}
</div> </div>
<div> <div>
{{if .Episodes}}
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<input type="hidden" name="next" value="/admin/podcasts">
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all</button>
</form>
{{end}}
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/refresh" class="d-inline"> <form method="post" action="/admin/podcasts/{{.Channel.ID}}/refresh" class="d-inline">
{{template "csrf_field" $.CSRFToken}} {{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button> <button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
+37
View File
@@ -21,6 +21,43 @@
</div> </div>
</div> </div>
<div class="card mt-3" style="max-width: 600px;">
<div class="card-body">
<h2 class="h6">Artist photos</h2>
{{if .Data.ArtistPhotosEnabled}}
<p class="text-secondary small mb-2">
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.
</p>
{{else}}
<p class="text-secondary small mb-2">
Automatic background fetching is disabled (<code>VIBESONIC_ENABLE_ARTIST_PHOTOS=false</code>), but this button still runs a one-off fetch — useful for checking why photos aren't showing up.
</p>
{{end}}
<form method="post" action="/admin/settings/artist-photos/refresh">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Fetch all artist photos now</button>
</form>
{{if .Data.ArtistPhotoResults}}
<table class="table table-sm vs-table mt-3 mb-0">
<thead><tr><th>Artist</th><th>Status</th><th>Error</th></tr></thead>
<tbody>
{{range .Data.ArtistPhotoResults}}
<tr>
<td>{{.ArtistName}}</td>
<td>
{{if .Error}}<span class="text-danger">{{.Status}}</span>
{{else if eq .Status "fetched"}}<span class="text-success">{{.Status}}</span>
{{else}}<span class="text-secondary">{{.Status}}</span>{{end}}
</td>
<td class="text-danger small">{{.Error}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
</div>
<div class="card mt-3" style="max-width: 600px;"> <div class="card mt-3" style="max-width: 600px;">
<div class="card-body"> <div class="card-body">
<h2 class="h6">External authentication</h2> <h2 class="h6">External authentication</h2>
+46
View File
@@ -0,0 +1,46 @@
{{define "admin_song_edit"}}{{template "header" .}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">Edit song</h2>
<form method="post" action="/admin/songs/{{.Data.ID}}/edit">
{{template "csrf_field" .CSRFToken}}
<div class="mb-2">
<label class="form-label small">Title</label>
<input type="text" name="title" class="form-control form-control-sm" value="{{.Data.Title}}" required>
</div>
<div class="mb-2">
<label class="form-label small">Sort title</label>
<input type="text" name="sort_title" class="form-control form-control-sm" value="{{.Data.SortTitle}}">
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small">Track #</label>
<input type="number" name="track_number" class="form-control form-control-sm" value="{{if .Data.TrackNumber.Valid}}{{.Data.TrackNumber.Int64}}{{end}}">
</div>
<div class="col-6">
<label class="form-label small">Disc #</label>
<input type="number" name="disc_number" class="form-control form-control-sm" value="{{if .Data.DiscNumber.Valid}}{{.Data.DiscNumber.Int64}}{{end}}">
</div>
</div>
<div class="mb-2">
<label class="form-label small">Composer</label>
<input type="text" name="composer" class="form-control form-control-sm" value="{{.Data.Composer}}">
</div>
<div class="mb-3">
<label class="form-label small">Conductor</label>
<input type="text" name="conductor" class="form-control form-control-sm" value="{{.Data.Conductor}}">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="metadata_locked" value="1" id="metadata_locked" {{if .Data.MetadataLocked}}checked{{end}}>
<label class="form-check-label" for="metadata_locked">Lock metadata (prevent library rescans from overwriting these fields)</label>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<a href="/songs" class="btn btn-sm btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{template "footer" .}}{{end}}
+3
View File
@@ -11,6 +11,9 @@
{{template "csrf_field" .CSRFToken}} {{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button> <button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
</form> </form>
{{if and .User .User.IsAdmin}}
<a class="btn btn-sm btn-outline-secondary" href="/admin/albums/{{.Data.Album.ID}}/edit"><i class="bi bi-pencil"></i> Edit album</a>
{{end}}
</div> </div>
</div> </div>
{{template "song_rows" .Data.Songs}} {{template "song_rows" .Data.Songs}}
+10
View File
@@ -1,4 +1,14 @@
{{define "artist"}}{{template "header" .}} {{define "artist"}}{{template "header" .}}
<div class="d-flex gap-3 mb-4">
<img class="vs-card-art" style="width:180px;border-radius:50%;" src="{{if .Data.Artist.CoverArtID}}/cover-art/{{.Data.Artist.CoverArtID}}?size=400{{else}}/static/img/default-art.svg{{end}}" alt="">
<div>
<h2 class="h4 mb-1">{{.Data.Artist.Name}}</h2>
{{if .Data.Artist.Bio}}<p class="text-secondary mb-2">{{.Data.Artist.Bio}}</p>{{end}}
{{if and .User .User.IsAdmin}}
<a class="btn btn-sm btn-outline-secondary" href="/admin/artists/{{.Data.Artist.ID}}/edit"><i class="bi bi-pencil"></i> Edit artist</a>
{{end}}
</div>
</div>
<div class="vs-grid mb-3"> <div class="vs-grid mb-3">
{{range .Data.Albums}}{{template "album_card" .}}{{end}} {{range .Data.Albums}}{{template "album_card" .}}{{end}}
</div> </div>
+8 -2
View File
@@ -8,7 +8,7 @@
{{define "artist_card"}} {{define "artist_card"}}
<a href="/artists/{{.ID}}" class="vs-card"> <a href="/artists/{{.ID}}" class="vs-card">
<img class="vs-card-art" style="border-radius:50%;" src="/static/img/default-art.svg" alt="" loading="lazy"> <img class="vs-card-art" style="border-radius:50%;" src="{{if .CoverArtID}}/cover-art/{{.CoverArtID}}?size=300{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
<div class="vs-card-title">{{.Name}}</div> <div class="vs-card-title">{{.Name}}</div>
</a> </a>
{{end}} {{end}}
@@ -26,11 +26,17 @@
data-cover="{{$s.CoverArtID}}" data-cover="{{$s.CoverArtID}}"
data-starred="{{if $s.Starred}}1{{else}}0{{end}}"> data-starred="{{if $s.Starred}}1{{else}}0{{end}}">
<td class="vs-track-num">{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}}</td> <td class="vs-track-num">{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}}</td>
<td class="vs-song-art-cell">
<img class="vs-song-art" src="{{if $s.CoverArtID}}/cover-art/{{$s.CoverArtID}}?size=80{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
</td>
<td> <td>
<div class="fw-semibold">{{$s.Title}}</div> <div class="fw-semibold">{{$s.Title}}</div>
{{if $s.AlbumTitle}}<div class="text-secondary small">{{$s.ArtistName}} — {{$s.AlbumTitle}}</div>{{else}}<div class="text-secondary small">{{$s.ArtistName}}</div>{{end}} {{if $s.AlbumTitle}}<div class="text-secondary small">{{$s.ArtistName}} — {{$s.AlbumTitle}}</div>{{else}}<div class="text-secondary small">{{$s.ArtistName}}</div>{{end}}
</td> </td>
<td class="text-end text-secondary small">{{fmtDuration $s.DurationSeconds}}</td> <td class="text-end text-secondary small">
{{fmtDuration $s.DurationSeconds}}
{{if $s.IsAdmin}}<a href="/admin/songs/{{$s.ID}}/edit" title="Edit song" class="text-secondary ms-2"><i class="bi bi-pencil"></i></a>{{end}}
</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
+1 -1
View File
@@ -19,7 +19,7 @@
</script> </script>
</head> </head>
<body> <body>
<script>window.vsCSRFToken = {{.CSRFToken}};</script> <script data-vs-csrf="{{.CSRFToken}}">window.vsCSRFToken = {{.CSRFToken}};</script>
<header class="vs-topbar d-flex align-items-center justify-content-between"> <header class="vs-topbar d-flex align-items-center justify-content-between">
<a href="/" class="vs-brand d-flex align-items-center gap-2"> <a href="/" class="vs-brand d-flex align-items-center gap-2">
<i class="bi bi-vinyl-fill"></i> <i class="bi bi-vinyl-fill"></i>
+9 -1
View File
@@ -3,7 +3,15 @@
{{range .Data}} {{range .Data}}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h2 class="h6">{{.Channel.Title}}</h2> <div class="d-flex align-items-center justify-content-between gap-2">
<h2 class="h6 mb-0">{{.Channel.Title}}</h2>
{{if .Episodes}}
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all episodes</button>
</form>
{{end}}
</div>
<p class="text-secondary small">{{.Channel.Description}}</p> <p class="text-secondary small">{{.Channel.Description}}</p>
<table class="table table-sm vs-table mb-0"> <table class="table table-sm vs-table mb-0">
<tbody> <tbody>