weee
This commit is contained in:
@@ -16,8 +16,10 @@ except the session secret.
|
||||
| `VIBESONIC_MPV_PATH` | `mpv` (from `PATH`) | Path to the `mpv` binary, used as the Subsonic jukebox's playback backend. If not found, the jukebox is administratively unavailable and `jukeboxControl` returns a clear error instead of a fake success. |
|
||||
| `VIBESONIC_SCAN_INTERVAL` | `1h` | How often to rescan enabled music folders, as a Go duration (`30m`, `6h`, ...). A startup scan always runs regardless of this value. |
|
||||
| `VIBESONIC_SESSION_SECRET` | *(none)* | Reserved for future use hardening session cookies beyond their database-backed random IDs. Not required to run, but recommended to set (e.g. `openssl rand -hex 32`) — VibeSonic logs a warning at startup if it's empty. |
|
||||
| `VIBESONIC_COOKIE_SECURE` | `true` | Whether session/CSRF cookies get the `Secure` attribute. Set to `false` only for local development over plain HTTP. |
|
||||
| `VIBESONIC_COOKIE_SECURE` | `false` | Whether session/CSRF cookies get the `Secure` attribute. Set to `true` when VibeSonic is served over HTTPS (including through a TLS-terminating reverse proxy); leave it off for direct plain-HTTP access. |
|
||||
| `VIBESONIC_ENABLE_EXTERNAL_METADATA` | `false` | Enable optional MusicBrainz/Cover Art Archive lookups for songs/albums that already carry a locally-tagged MusicBrainz ID. VibeSonic works fully offline with this off; it's the only feature that makes outbound Internet requests. |
|
||||
| `VIBESONIC_ENABLE_ARTIST_PHOTOS` | `false` | Enable auto-fetching an artist photo for every artist with no manually-set one: fanart.tv (only when both `VIBESONIC_FANART_TV_API_KEY` is set and the artist has a tagged MusicBrainz ID), falling back to a direct name search against Wikidata/Wikimedia Commons — this never calls the MusicBrainz API itself. Off by default for the same offline-first reason as `VIBESONIC_ENABLE_EXTERNAL_METADATA`. An admin can always set a photo manually from an artist's edit page regardless of this setting, and can trigger a one-off bulk fetch (with per-artist results, including any errors) from Admin → System Settings regardless of this toggle. |
|
||||
| `VIBESONIC_FANART_TV_API_KEY` | *(none)* | Optional personal [fanart.tv](https://fanart.tv/) API key, tried first (for artists with a tagged MusicBrainz ID) when `VIBESONIC_ENABLE_ARTIST_PHOTOS` is on. Leave unset to go straight to the Wikidata name search. |
|
||||
| `VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS` | `false` | Allow podcast feed/enclosure URLs to resolve to loopback/LAN/link-local addresses. Leave this off unless you intentionally host podcast feeds on a private network — see [`docs/security.md`](security.md#podcast-fetching-ssrf-protections). |
|
||||
|
||||
## External authentication
|
||||
|
||||
+29
-2
@@ -21,8 +21,9 @@ authentication model in detail.
|
||||
comparison itself uses a constant-time compare
|
||||
(`crypto/subtle.ConstantTimeCompare`) to avoid a timing side-channel.
|
||||
- **API keys** are stored and looked up by hash, never in plaintext.
|
||||
- **Sessions** are random opaque tokens in an HTTP-only, `Secure`
|
||||
(configurable via `VIBESONIC_COOKIE_SECURE`, on by default) cookie.
|
||||
- **Sessions** are random opaque tokens in an HTTP-only cookie. Set
|
||||
`VIBESONIC_COOKIE_SECURE=true` for HTTPS deployments so browsers only send
|
||||
session and CSRF cookies over secure connections.
|
||||
- **External (trusted-proxy) authentication** is off by default and,
|
||||
when enabled, trusts an identity-asserting header only from
|
||||
configured source IPs verified against the real TCP peer address —
|
||||
@@ -90,6 +91,32 @@ before the full image is decoded into memory, rejecting anything over
|
||||
40 megapixels. This prevents a small, highly-compressed image file from
|
||||
forcing an out-of-proportion memory allocation ("decompression bomb").
|
||||
|
||||
## Artist photos and manual metadata editing
|
||||
|
||||
When `VIBESONIC_ENABLE_ARTIST_PHOTOS` is on, VibeSonic looks up an
|
||||
artist photo from fanart.tv (only for an artist with both a tagged
|
||||
MusicBrainz ID and `VIBESONIC_FANART_TV_API_KEY` set) or, failing
|
||||
that, by searching Wikidata directly by artist name and reading the
|
||||
matched entity's image from Wikimedia Commons — both
|
||||
externally-hosted services, same trust model as the existing
|
||||
MusicBrainz/Cover Art Archive lookups used elsewhere. This path
|
||||
deliberately never calls the MusicBrainz API itself (its public
|
||||
instance proved too rate-limited to depend on); a name-only match is
|
||||
necessarily less precise, so it requires an exact (case-insensitive)
|
||||
label match and only weakly prefers a music-flavored Wikidata
|
||||
description, rather than fabricating a stricter guarantee it can't
|
||||
actually make. The resulting image download, and any admin-supplied
|
||||
photo/cover-art URL entered on an artist or album's edit page, is
|
||||
fetched through the same `internal/netguard` SSRF-safe client described
|
||||
above (dial-time IP validation, capped redirects, size-limited
|
||||
response). Uploaded photo/cover files are validated by actually
|
||||
decoding them as an image before being written to disk.
|
||||
|
||||
Editing artist/album/song metadata (`/admin/artists/{id}/edit`,
|
||||
`/admin/albums/{id}/edit`, `/admin/songs/{id}/edit`) and overriding a
|
||||
photo/cover are admin-only actions, gated the same way as every other
|
||||
`/admin/*` route.
|
||||
|
||||
## Transcoding
|
||||
|
||||
`ffmpeg`/`ffprobe` are invoked with `exec.CommandContext` and an
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,17 @@ type Config struct {
|
||||
// to the outside world.
|
||||
EnableExternalMetadata bool
|
||||
|
||||
// EnableArtistPhotos opts into looking up an artist photo (fanart.tv,
|
||||
// falling back to Wikidata/Wikimedia Commons) for artists that carry
|
||||
// a locally-tagged MusicBrainz ID. Off by default for the same
|
||||
// offline-first reason as EnableExternalMetadata.
|
||||
EnableArtistPhotos bool
|
||||
|
||||
// FanartTVAPIKey is an optional personal API key for fanart.tv,
|
||||
// tried before the free Wikidata fallback when EnableArtistPhotos is
|
||||
// on. Empty means "skip fanart.tv".
|
||||
FanartTVAPIKey string
|
||||
|
||||
// PodcastsAllowPrivateNetworks disables SSRF protection on podcast
|
||||
// feed/enclosure fetching, allowing feeds hosted on loopback/LAN/
|
||||
// link-local addresses. Off by default: podcast URLs are supplied
|
||||
@@ -68,10 +79,16 @@ func Load() (Config, error) {
|
||||
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
|
||||
MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"),
|
||||
SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"),
|
||||
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true),
|
||||
// The built-in server speaks plain HTTP, so Secure cookies cannot be
|
||||
// the default: browsers would discard them during first-run setup.
|
||||
// Deployments terminating TLS at a reverse proxy should opt in.
|
||||
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", false),
|
||||
|
||||
EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false),
|
||||
|
||||
EnableArtistPhotos: envBool("VIBESONIC_ENABLE_ARTIST_PHOTOS", false),
|
||||
FanartTVAPIKey: os.Getenv("VIBESONIC_FANART_TV_API_KEY"),
|
||||
|
||||
PodcastsAllowPrivateNetworks: envBool("VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS", false),
|
||||
}
|
||||
cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db"))
|
||||
|
||||
@@ -25,6 +25,9 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if cfg.DBPath != "/var/lib/vibesonic/vibesonic.db" {
|
||||
t.Errorf("DBPath = %q, want default under DataDir", cfg.DBPath)
|
||||
}
|
||||
if cfg.CookieSecure {
|
||||
t.Error("CookieSecure = true, want false for the default plain-HTTP server")
|
||||
}
|
||||
if cfg.ExtAuth.Enabled {
|
||||
t.Errorf("ExtAuth.Enabled = true, want false by default")
|
||||
}
|
||||
@@ -33,6 +36,17 @@ func TestLoadDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecureCookiesOptIn(t *testing.T) {
|
||||
t.Setenv("VIBESONIC_COOKIE_SECURE", "true")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.CookieSecure {
|
||||
t.Error("CookieSecure = false, want true when explicitly enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMusicDirs(t *testing.T) {
|
||||
withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "})
|
||||
cfg, err := Load()
|
||||
|
||||
@@ -77,7 +77,11 @@ func (s *Store) ExtractEmbedded(ctx context.Context, mediaPath string, streamInd
|
||||
"-map", fmt.Sprintf("0:%d", streamIndex),
|
||||
"-frames:v", "1",
|
||||
"-c:v", "copy",
|
||||
"--", tmp.Name(),
|
||||
// Temporary files deliberately have a neutral .img suffix. Tell
|
||||
// ffmpeg which muxer to use instead of asking it to infer one from
|
||||
// that suffix. ffmpeg does not accept `--` as an end-of-options
|
||||
// marker, so including it here made every embedded-art request fail.
|
||||
"-f", "image2", tmp.Name(),
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package coverart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
@@ -9,6 +10,33 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractEmbeddedSuppliesExplicitOutputFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
argsPath := filepath.Join(dir, "args")
|
||||
fakeFFmpeg := filepath.Join(dir, "ffmpeg")
|
||||
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGS_PATH\"\n"
|
||||
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ARGS_PATH", argsPath)
|
||||
|
||||
store := New(dir, fakeFFmpeg)
|
||||
path, err := store.ExtractEmbedded(context.Background(), filepath.Join(dir, "song.mp3"), 2)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractEmbedded: %v", err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
|
||||
args, err := os.ReadFile(argsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(args)
|
||||
if got != "-y\n-v\nerror\n-i\n"+filepath.Join(dir, "song.mp3")+"\n-map\n0:2\n-frames:v\n1\n-c:v\ncopy\n-f\nimage2\n"+path+"\n" {
|
||||
t.Fatalf("unexpected ffmpeg arguments:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJPEG(t *testing.T, path string, w, h int) {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
|
||||
@@ -11,6 +11,7 @@ type CoverArt struct {
|
||||
ID string
|
||||
AlbumID sql.NullString
|
||||
MediaFileID sql.NullString
|
||||
ArtistID sql.NullString
|
||||
Source string
|
||||
SourcePath string
|
||||
MimeType string
|
||||
@@ -19,7 +20,7 @@ type CoverArt struct {
|
||||
Height int
|
||||
}
|
||||
|
||||
const coverArtSelect = `SELECT id, album_id, media_file_id, source, source_path, mime_type, content_hash, width, height FROM cover_art`
|
||||
const coverArtSelect = `SELECT id, album_id, media_file_id, artist_id, source, source_path, mime_type, content_hash, width, height FROM cover_art`
|
||||
|
||||
func GetCoverArt(db *sql.DB, id string) (*CoverArt, error) {
|
||||
return scanCoverArt(db.QueryRow(coverArtSelect+` WHERE id = ?`, id))
|
||||
@@ -31,7 +32,7 @@ func GetCoverArtForAlbum(db *sql.DB, albumID string) (*CoverArt, error) {
|
||||
|
||||
func scanCoverArt(row *sql.Row) (*CoverArt, error) {
|
||||
var c CoverArt
|
||||
err := row.Scan(&c.ID, &c.AlbumID, &c.MediaFileID, &c.Source, &c.SourcePath, &c.MimeType, &c.ContentHash, &c.Width, &c.Height)
|
||||
err := row.Scan(&c.ID, &c.AlbumID, &c.MediaFileID, &c.ArtistID, &c.Source, &c.SourcePath, &c.MimeType, &c.ContentHash, &c.Width, &c.Height)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -71,6 +72,36 @@ func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mediaFileID, m
|
||||
return GetCoverArt(db, id)
|
||||
}
|
||||
|
||||
// UpsertArtistCoverArt records (or replaces) an artist's photo and links
|
||||
// it as the artist's cover art. manual=true marks it as an admin
|
||||
// override (see database.SetArtistCoverArt); manual=false is the
|
||||
// auto-fetch path, which never overwrites an existing manual override.
|
||||
func UpsertArtistCoverArt(db *sql.DB, artistID, source, sourcePath, mimeType, contentHash string, width, height int, manual bool) (*CoverArt, error) {
|
||||
var id string
|
||||
err := db.QueryRow(`SELECT id FROM cover_art WHERE artist_id = ?`, artistID).Scan(&id)
|
||||
switch {
|
||||
case err == nil:
|
||||
_, err = db.Exec(`UPDATE cover_art SET source=?, source_path=?, mime_type=?, content_hash=?, width=?, height=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||
source, sourcePath, mimeType, contentHash, width, height, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
id = idgen.New("ca")
|
||||
_, err = db.Exec(`INSERT INTO cover_art (id, artist_id, source, source_path, mime_type, content_hash, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, artistID, source, sourcePath, mimeType, contentHash, width, height)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
if err := SetArtistCoverArt(db, artistID, sql.NullString{String: id, Valid: true}, manual); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetCoverArt(db, id)
|
||||
}
|
||||
|
||||
func nullEmptyStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
|
||||
@@ -15,6 +15,23 @@ type Artist struct {
|
||||
Name string
|
||||
SortName string
|
||||
MusicBrainzID string
|
||||
Bio string
|
||||
CoverArtID sql.NullString
|
||||
PhotoManual bool
|
||||
}
|
||||
|
||||
const artistSelect = `SELECT id, name, sort_name, musicbrainz_id, bio, cover_art_id, photo_manual FROM artists`
|
||||
|
||||
func scanArtist(row *sql.Row) (*Artist, error) {
|
||||
var a Artist
|
||||
err := row.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// FindOrCreateArtist looks up an artist by (name, musicbrainz_id) and
|
||||
@@ -43,19 +60,11 @@ func FindOrCreateArtist(db *sql.DB, name, sortName, musicBrainzID string) (*Arti
|
||||
}
|
||||
|
||||
func GetArtist(db *sql.DB, id string) (*Artist, error) {
|
||||
var a Artist
|
||||
err := db.QueryRow(`SELECT id, name, sort_name, musicbrainz_id FROM artists WHERE id = ?`, id).Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
return scanArtist(db.QueryRow(artistSelect+` WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func ListArtists(db *sql.DB) ([]*Artist, error) {
|
||||
rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id FROM artists a
|
||||
rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id, a.bio, a.cover_art_id, a.photo_manual FROM artists a
|
||||
JOIN album_artists aa ON aa.artist_id = a.id
|
||||
ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
@@ -65,7 +74,7 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
|
||||
var out []*Artist
|
||||
for rows.Next() {
|
||||
var a Artist
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &a)
|
||||
@@ -73,6 +82,25 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateArtist overwrites an artist's name, sort name, and bio. It never
|
||||
// touches the artist's photo (see SetArtistCoverArt).
|
||||
func UpdateArtist(db *sql.DB, id, name, sortName, bio string) error {
|
||||
_, err := db.Exec(`UPDATE artists SET name = ?, sort_name = ?, bio = ? WHERE id = ?`, name, sortName, bio, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetArtistCoverArt links (or unlinks) an artist's cover_art row.
|
||||
// manual=true records an admin override; manual=false is the auto-fetch
|
||||
// path, which is guarded to never overwrite an existing manual override.
|
||||
func SetArtistCoverArt(db *sql.DB, artistID string, coverArtID sql.NullString, manual bool) error {
|
||||
if manual {
|
||||
_, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 1 WHERE id = ?`, nullStr(coverArtID), artistID)
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 0 WHERE id = ? AND photo_manual = 0`, nullStr(coverArtID), artistID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Genres ---
|
||||
|
||||
type Genre struct {
|
||||
@@ -223,6 +251,14 @@ func scanAlbums(rows *sql.Rows) ([]*Album, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateAlbum overwrites an album's editable metadata fields. It never
|
||||
// touches the album's cover art (see SetAlbumCoverArt).
|
||||
func UpdateAlbum(db *sql.DB, id, title, sortTitle string, year, originalYear sql.NullInt64, label string) error {
|
||||
_, err := db.Exec(`UPDATE albums SET title = ?, sort_title = ?, year = ?, original_year = ?, record_label = ? WHERE id = ?`,
|
||||
title, sortTitle, nullInt(year), nullInt(originalYear), label, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func SetAlbumCoverArt(db *sql.DB, albumID, coverArtID string) error {
|
||||
_, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID)
|
||||
return err
|
||||
@@ -260,11 +296,12 @@ type Song struct {
|
||||
ReplayGainTrackPeak sql.NullFloat64
|
||||
ReplayGainAlbumGainDB sql.NullFloat64
|
||||
ReplayGainAlbumPeak sql.NullFloat64
|
||||
MetadataLocked bool
|
||||
}
|
||||
|
||||
const songSelect = `SELECT id, media_file_id, album_id, title, sort_title, track_number, track_total, disc_number, disc_total,
|
||||
composer, conductor, isrc, bpm, musicbrainz_track_id, musicbrainz_recording_id,
|
||||
replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak FROM songs`
|
||||
replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak, metadata_locked FROM songs`
|
||||
|
||||
func GetSong(db *sql.DB, id string) (*Song, error) {
|
||||
return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id))
|
||||
@@ -278,7 +315,7 @@ func scanSong(row *sql.Row) (*Song, error) {
|
||||
var s Song
|
||||
err := row.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal,
|
||||
&s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID,
|
||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak)
|
||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -312,7 +349,7 @@ func scanSongs(rows *sql.Rows) ([]*Song, error) {
|
||||
var s Song
|
||||
if err := rows.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal,
|
||||
&s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID,
|
||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak); err != nil {
|
||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &s)
|
||||
@@ -354,6 +391,20 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.MetadataLocked {
|
||||
// An admin has hand-edited title/sort_title/track_number/
|
||||
// disc_number/composer/conductor for this song; leave those
|
||||
// alone and only refresh the fields that come straight off
|
||||
// the file itself and were never exposed for editing.
|
||||
_, err = db.Exec(`UPDATE songs SET album_id=?, track_total=?, disc_total=?, disc_subtitle=?,
|
||||
comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
||||
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||
nullStr(in.AlbumID), nullInt(in.TrackTotal), nullInt(in.DiscTotal), in.DiscSubtitle,
|
||||
in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID,
|
||||
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak),
|
||||
existing.ID)
|
||||
} else {
|
||||
_, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?,
|
||||
composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
||||
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?,
|
||||
@@ -362,6 +413,7 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
|
||||
in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID,
|
||||
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak),
|
||||
existing.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -382,6 +434,15 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
|
||||
return GetSong(db, id)
|
||||
}
|
||||
|
||||
// UpdateSongMetadata overwrites a song's editable fields. locked=true
|
||||
// sets metadata_locked so a later library rescan (UpsertSong) leaves
|
||||
// these fields alone instead of overwriting them from the file's tags.
|
||||
func UpdateSongMetadata(db *sql.DB, id, title, sortTitle string, trackNumber, discNumber sql.NullInt64, composer, conductor string, locked bool) error {
|
||||
_, err := db.Exec(`UPDATE songs SET title=?, sort_title=?, track_number=?, disc_number=?, composer=?, conductor=?, metadata_locked=? WHERE id=?`,
|
||||
title, sortTitle, nullInt(trackNumber), nullInt(discNumber), composer, conductor, locked, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func ClearSongArtists(db *sql.DB, songID string) error {
|
||||
_, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID)
|
||||
return err
|
||||
|
||||
@@ -111,7 +111,7 @@ func ListGenreStats(db *sql.DB) ([]GenreStat, error) {
|
||||
func SongsByGenre(db *sql.DB, genreName string, limit, offset int) ([]*Song, error) {
|
||||
rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||
FROM songs s
|
||||
JOIN song_genres sg ON sg.song_id = s.id
|
||||
JOIN genres g ON g.id = sg.genre_id
|
||||
|
||||
@@ -37,7 +37,7 @@ func FindArtistByName(db *sql.DB, name string) (*Artist, error) {
|
||||
func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
||||
rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||
FROM songs s
|
||||
JOIN song_artists sa ON sa.song_id = s.id
|
||||
LEFT JOIN (SELECT song_id, COUNT(*) AS plays FROM playback_history GROUP BY song_id) ph ON ph.song_id = s.id
|
||||
@@ -58,7 +58,7 @@ func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
||||
func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, error) {
|
||||
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||
FROM songs s
|
||||
JOIN song_genres sg ON sg.song_id = s.id
|
||||
WHERE sg.genre_id IN (SELECT genre_id FROM song_genres WHERE song_id = ?)
|
||||
@@ -78,7 +78,7 @@ func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, err
|
||||
func SimilarSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
||||
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||
FROM songs s
|
||||
JOIN song_genres sg ON sg.song_id = s.id
|
||||
WHERE sg.genre_id IN (
|
||||
|
||||
@@ -252,6 +252,30 @@ func (m *Manager) DownloadEpisode(ctx context.Context, episodeID string) error {
|
||||
return database.SetPodcastEpisodeMediaFile(m.DB, episodeID, mf.ID)
|
||||
}
|
||||
|
||||
// DownloadChannel downloads every episode in a channel that is not already
|
||||
// complete or in progress. Downloads are run in feed order and still pass
|
||||
// through the manager's concurrency limiter.
|
||||
func (m *Manager) DownloadChannel(ctx context.Context, channelID string) error {
|
||||
episodes, err := database.ListPodcastEpisodes(m.DB, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, episode := range episodes {
|
||||
if episode.Status == "completed" || episode.Status == "downloading" {
|
||||
continue
|
||||
}
|
||||
if err := m.DownloadEpisode(ctx, episode.ID); err != nil {
|
||||
if m.Log != nil {
|
||||
m.Log.Warn("podcast episode download failed", "episode", episode.ID, "err", err)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string) error {
|
||||
if err := netguard.ValidateURL(sourceURL); err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -283,10 +284,10 @@ func (h *Web) handleAdminScanner(w http.ResponseWriter, r *http.Request) {
|
||||
// --- System settings ---
|
||||
|
||||
func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderAdminSettings(w, r, "", "")
|
||||
h.renderAdminSettings(w, r, "", "", nil)
|
||||
}
|
||||
|
||||
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string) {
|
||||
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string, photoResults []ArtistPhotoOutcome) {
|
||||
trusted := ""
|
||||
for i, p := range h.Config.ExtAuth.TrustedSources {
|
||||
if i > 0 {
|
||||
@@ -301,6 +302,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
|
||||
ExtAuthEnabled bool
|
||||
ExtAuthTrustedSources string
|
||||
ExtAuthHeader string
|
||||
ArtistPhotosEnabled bool
|
||||
ArtistPhotoResults []ArtistPhotoOutcome
|
||||
}{
|
||||
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
|
||||
FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath),
|
||||
@@ -308,6 +311,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
|
||||
ExtAuthEnabled: h.Config.ExtAuth.Enabled,
|
||||
ExtAuthTrustedSources: trusted,
|
||||
ExtAuthHeader: h.Config.ExtAuth.UserHeader,
|
||||
ArtistPhotosEnabled: h.Config.EnableArtistPhotos,
|
||||
ArtistPhotoResults: photoResults,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -318,11 +323,52 @@ func (h *Web) handleAdminSettingsUpdate(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
interval := r.FormValue("scan_interval")
|
||||
if _, err := time.ParseDuration(interval); err != nil {
|
||||
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger")
|
||||
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger", nil)
|
||||
return
|
||||
}
|
||||
_ = database.SetSetting(h.DB, "scan_interval", interval)
|
||||
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
|
||||
_ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_path"))
|
||||
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success")
|
||||
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success", nil)
|
||||
}
|
||||
|
||||
// handleAdminArtistPhotosRefresh runs a synchronous, admin-triggered
|
||||
// fetch of every eligible artist's photo and reports the outcome for
|
||||
// each one — including failures — rather than the silent best-effort
|
||||
// background fetch normally used when an artist's page is viewed.
|
||||
func (h *Web) handleAdminArtistPhotosRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
// Unlike the best-effort background fetch on an artist's page (gated
|
||||
// by EnableArtistPhotos, since that one runs unattended on every
|
||||
// visit), this is an explicit, one-off admin action — it runs
|
||||
// regardless of the config toggle so it doubles as a way to check
|
||||
// what's actually happening (bad network, no confident name match,
|
||||
// an API error) without editing the environment first.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
results, err := h.RefreshAllArtistPhotos(ctx)
|
||||
if err != nil {
|
||||
h.renderAdminSettings(w, r, "Artist photo refresh failed: "+err.Error(), "danger", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var fetched, notFound, skipped, failed int
|
||||
for _, res := range results {
|
||||
switch {
|
||||
case res.Error != "":
|
||||
failed++
|
||||
case res.Status == "fetched":
|
||||
fetched++
|
||||
case res.Status == "no photo found":
|
||||
notFound++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
kind := "success"
|
||||
if failed > 0 {
|
||||
kind = "warning"
|
||||
}
|
||||
flash := fmt.Sprintf("Artist photo refresh complete: %d fetched, %d no photo found, %d skipped, %d failed (see below).",
|
||||
fetched, notFound, skipped, failed)
|
||||
h.renderAdminSettings(w, r, flash, kind, results)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
// --- Artists ---
|
||||
|
||||
func (h *Web) handleAdminArtistEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
artist, err := database.GetArtist(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_artist_edit", "artists", "Edit artist: "+artist.Name, artist)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminArtistUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetArtist(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxArtistPhotoBytes); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateArtist(h.DB, id, r.FormValue("name"), r.FormValue("sort_name"), r.FormValue("bio")); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.FormValue("remove_photo") == "1":
|
||||
if err := database.SetArtistCoverArt(h.DB, id, sql.NullString{}, false); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
default:
|
||||
if !h.applyPhotoOverride(w, r, "photo_file", "photo_url", func(localPath, contentHash, mimeType string) error {
|
||||
_, err := database.UpsertArtistCoverArt(h.DB, id, "external", localPath, mimeType, contentHash, 0, 0, true)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/artists/"+id, http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Albums ---
|
||||
|
||||
func (h *Web) handleAdminAlbumEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
album, err := database.GetAlbum(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_album_edit", "albums", "Edit album: "+album.Title, album)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminAlbumUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetAlbum(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxArtistPhotoBytes); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateAlbum(h.DB, id, r.FormValue("title"), r.FormValue("sort_title"),
|
||||
formNullInt(r, "year"), formNullInt(r, "original_year"), r.FormValue("record_label")); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.applyPhotoOverride(w, r, "cover_file", "cover_url", func(localPath, contentHash, mimeType string) error {
|
||||
_, err := database.UpsertAlbumCoverArt(h.DB, id, "external", localPath, "", mimeType, contentHash, 0, 0)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/albums/"+id, http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Songs ---
|
||||
|
||||
func (h *Web) handleAdminSongEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
song, err := database.GetSong(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_song_edit", "songs", "Edit song: "+song.Title, song)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminSongUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetSong(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
locked := r.FormValue("metadata_locked") == "1"
|
||||
if err := database.UpdateSongMetadata(h.DB, id, r.FormValue("title"), r.FormValue("sort_title"),
|
||||
formNullInt(r, "track_number"), formNullInt(r, "disc_number"), r.FormValue("composer"), r.FormValue("conductor"), locked); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/songs", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- shared helpers ---
|
||||
|
||||
// formNullInt parses a form field as an integer, returning an invalid
|
||||
// sql.NullInt64 when the field is blank or unparsable rather than
|
||||
// erroring the request — the same tolerant behavior as the rest of
|
||||
// VibeSonic's admin forms.
|
||||
func formNullInt(r *http.Request, name string) sql.NullInt64 {
|
||||
raw := strings.TrimSpace(r.FormValue(name))
|
||||
if raw == "" {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: n, Valid: true}
|
||||
}
|
||||
|
||||
// applyPhotoOverride implements the common "upload a file, or give a
|
||||
// URL, or leave the existing photo/cover alone" logic shared by the
|
||||
// artist and album edit forms. store is called with the resulting local
|
||||
// file's path/hash/mime once one is available. Returns false (having
|
||||
// already written an HTTP error response) on failure.
|
||||
func (h *Web) applyPhotoOverride(w http.ResponseWriter, r *http.Request, fileField, urlField string, store func(localPath, contentHash, mimeType string) error) bool {
|
||||
if file, _, err := r.FormFile(fileField); err == nil {
|
||||
defer file.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(file, maxArtistPhotoBytes+1))
|
||||
if err != nil {
|
||||
http.Error(w, "could not read uploaded image", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if len(body) > maxArtistPhotoBytes {
|
||||
http.Error(w, "uploaded image exceeds maximum allowed size", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
localPath, contentHash, mimeType, err := h.storeImageBytes(body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := store(localPath, contentHash, mimeType); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
photoURL := strings.TrimSpace(r.FormValue(urlField))
|
||||
if photoURL == "" {
|
||||
return true
|
||||
}
|
||||
if err := netguard.ValidateURL(photoURL); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
localPath, contentHash, mimeType, err := h.downloadImage(r.Context(), photoURL)
|
||||
if err != nil {
|
||||
http.Error(w, "could not download image: "+err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := store(localPath, contentHash, mimeType); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/artistphoto"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
// maxArtistPhotoBytes bounds a single downloaded artist photo, the same
|
||||
// way internal/podcasts bounds enclosure downloads.
|
||||
const maxArtistPhotoBytes = 8 << 20
|
||||
|
||||
// artistPhotoCacheTTL mirrors internal/subsonic's enrichmentCacheTTL:
|
||||
// artist photos change essentially never, so a long TTL avoids
|
||||
// repeatedly hitting fanart.tv/Wikidata for the same artist.
|
||||
const artistPhotoCacheTTL = 30 * 24 * time.Hour
|
||||
|
||||
var artistPhotoClient = &artistphoto.Client{}
|
||||
|
||||
func (h *Web) artistPhotoDir() string {
|
||||
return filepath.Join(h.Config.DataDir, "artist-photos")
|
||||
}
|
||||
|
||||
// maybeFetchArtistPhoto kicks off a best-effort background fetch of an
|
||||
// artist's photo the first time their page is viewed. It never blocks
|
||||
// the caller and is a no-op unless artist photos are enabled and no
|
||||
// photo (auto-fetched or manually set) is already on file — every
|
||||
// artist is attempted regardless of whether it has a tagged
|
||||
// MusicBrainz ID, since artistphoto.Client can resolve a photo by name
|
||||
// alone via Wikidata.
|
||||
func (h *Web) maybeFetchArtistPhoto(artist *database.Artist) {
|
||||
if !h.Config.EnableArtistPhotos || artist.CoverArtID.Valid || artist.PhotoManual {
|
||||
return
|
||||
}
|
||||
go h.fetchAndStoreArtistPhoto(artist)
|
||||
}
|
||||
|
||||
func (h *Web) fetchAndStoreArtistPhoto(artist *database.Artist) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
if status, err := h.fetchAndStoreArtistPhotoResult(ctx, artist, false); err != nil {
|
||||
h.Log.Warn("artist photo fetch failed", "artist_id", artist.ID, "status", status, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// artistPhotoCacheKey is what a lookup result is cached/looked-up under:
|
||||
// the artist's MusicBrainz ID when tagged, or else a name-derived key
|
||||
// (real MBIDs are UUIDs, so this can never collide with one) — an
|
||||
// artist resolved by name still benefits from the same negative/
|
||||
// positive caching as a tagged one.
|
||||
func artistPhotoCacheKey(artist *database.Artist) string {
|
||||
if artist.MusicBrainzID != "" {
|
||||
return artist.MusicBrainzID
|
||||
}
|
||||
return "name:" + artist.Name
|
||||
}
|
||||
|
||||
// resolveArtistPhotoURL resolves a photo URL for artist, using the
|
||||
// cached result unless bypassCache is set (the manual "refresh all"
|
||||
// admin action bypasses it, so a previous failed/empty lookup doesn't
|
||||
// hide a photo that might exist now).
|
||||
func (h *Web) resolveArtistPhotoURL(ctx context.Context, artist *database.Artist, bypassCache bool) (string, error) {
|
||||
cacheKey := artistPhotoCacheKey(artist)
|
||||
if !bypassCache {
|
||||
if cached, ok := readArtistPhotoCache(h.DB, cacheKey); ok {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
u, err := artistPhotoClient.FetchPhotoURL(ctx, artist.MusicBrainzID, artist.Name, h.Config.FanartTVAPIKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if raw, err := json.Marshal(struct {
|
||||
URL string `json:"url"`
|
||||
}{u}); err == nil {
|
||||
_ = database.PutCachedExternalMetadata(h.DB, "artist_photo", cacheKey, "combined", string(raw))
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// fetchAndStoreArtistPhotoResult resolves and stores a photo for one
|
||||
// artist, returning a short human-readable status plus the underlying
|
||||
// error (if any) so callers that report to a person (RefreshAllArtistPhotos)
|
||||
// can show exactly what went wrong, not just log-and-forget.
|
||||
func (h *Web) fetchAndStoreArtistPhotoResult(ctx context.Context, artist *database.Artist, bypassCache bool) (status string, err error) {
|
||||
photoURL, err := h.resolveArtistPhotoURL(ctx, artist, bypassCache)
|
||||
if err != nil {
|
||||
return "lookup failed", err
|
||||
}
|
||||
if photoURL == "" {
|
||||
return "no photo found", nil
|
||||
}
|
||||
|
||||
localPath, contentHash, mimeType, err := h.downloadImage(ctx, photoURL)
|
||||
if err != nil {
|
||||
return "download failed", err
|
||||
}
|
||||
if _, err := database.UpsertArtistCoverArt(h.DB, artist.ID, "external", localPath, mimeType, contentHash, 0, 0, false); err != nil {
|
||||
return "store failed", err
|
||||
}
|
||||
return "fetched", nil
|
||||
}
|
||||
|
||||
// ArtistPhotoOutcome is the per-artist result of a bulk photo refresh
|
||||
// (see RefreshAllArtistPhotos), surfaced to an admin so failures are
|
||||
// visible instead of just falling back to the default placeholder with
|
||||
// no explanation.
|
||||
type ArtistPhotoOutcome struct {
|
||||
ArtistID string
|
||||
ArtistName string
|
||||
Status string
|
||||
Error string
|
||||
}
|
||||
|
||||
// RefreshAllArtistPhotos attempts to (re-)fetch a photo for every
|
||||
// artist with no manual photo override, bypassing the cached "nothing
|
||||
// found" result so a previous failure doesn't silently persist
|
||||
// forever. A small worker pool keeps a large library from serializing
|
||||
// one slow network call after another.
|
||||
func (h *Web) RefreshAllArtistPhotos(ctx context.Context) ([]ArtistPhotoOutcome, error) {
|
||||
artists, err := database.ListArtists(h.DB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]ArtistPhotoOutcome, len(artists))
|
||||
type job struct {
|
||||
idx int
|
||||
artist *database.Artist
|
||||
}
|
||||
jobs := make(chan job)
|
||||
|
||||
const workers = 4
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := range jobs {
|
||||
results[j.idx] = h.refreshOneArtistPhoto(ctx, j.artist)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i, a := range artists {
|
||||
jobs <- job{i, a}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(results, func(i, k int) bool { return results[i].ArtistName < results[k].ArtistName })
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (h *Web) refreshOneArtistPhoto(ctx context.Context, artist *database.Artist) ArtistPhotoOutcome {
|
||||
out := ArtistPhotoOutcome{ArtistID: artist.ID, ArtistName: artist.Name}
|
||||
if artist.PhotoManual {
|
||||
out.Status = "skipped (manual photo set)"
|
||||
return out
|
||||
}
|
||||
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
||||
defer cancel()
|
||||
status, err := h.fetchAndStoreArtistPhotoResult(fetchCtx, artist, true)
|
||||
out.Status = status
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readArtistPhotoCache(db *sql.DB, cacheKey string) (string, bool) {
|
||||
dataJSON, fetchedAt, found, err := database.GetCachedExternalMetadata(db, "artist_photo", cacheKey, "combined")
|
||||
if err != nil || !found || time.Since(fetchedAt) > artistPhotoCacheTTL {
|
||||
return "", false
|
||||
}
|
||||
var payload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return payload.URL, true
|
||||
}
|
||||
|
||||
// downloadImage fetches sourceURL through an SSRF-safe client
|
||||
// (internal/netguard, the same protection used for podcast feeds and
|
||||
// enclosures) and hands the body to storeImageBytes. Used both for
|
||||
// auto-fetched artist photos and admin-supplied photo/cover URLs.
|
||||
func (h *Web) downloadImage(ctx context.Context, sourceURL string) (localPath, contentHash, mimeType string, err error) {
|
||||
if err := netguard.ValidateURL(sourceURL); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
client := netguard.NewSafeHTTPClient(15*time.Second, false)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "VibeSonic/1.0 (+https://git.noroutes.net/NoRoutes/vibetunes)")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", "", fmt.Errorf("artist photo download: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxArtistPhotoBytes+1))
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if len(body) > maxArtistPhotoBytes {
|
||||
return "", "", "", fmt.Errorf("artist photo exceeds maximum allowed size of %d bytes", maxArtistPhotoBytes)
|
||||
}
|
||||
return h.storeImageBytes(body)
|
||||
}
|
||||
|
||||
// storeImageBytes validates that body actually decodes as an image,
|
||||
// then saves it under the artist-photos cache directory keyed by its
|
||||
// content hash (so re-fetching or re-uploading the same bytes reuses
|
||||
// the same file). Used both for artist photos and album cover
|
||||
// overrides — the directory name is a historical artifact, not a
|
||||
// restriction on what's stored there.
|
||||
func (h *Web) storeImageBytes(body []byte) (localPath, contentHash, mimeType string, err error) {
|
||||
if _, _, err := image.Decode(bytes.NewReader(body)); err != nil {
|
||||
return "", "", "", fmt.Errorf("not a valid image: %w", err)
|
||||
}
|
||||
mimeType = http.DetectContentType(body)
|
||||
sum := sha256.Sum256(body)
|
||||
contentHash = hex.EncodeToString(sum[:])[:16]
|
||||
|
||||
dir := h.artistPhotoDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
localPath = filepath.Join(dir, contentHash+extensionForMIME(mimeType))
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, contentHash, mimeType, nil
|
||||
}
|
||||
tmp := localPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.Rename(tmp, localPath); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return localPath, contentHash, mimeType, nil
|
||||
}
|
||||
|
||||
func extensionForMIME(mimeType string) string {
|
||||
switch mimeType {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
default:
|
||||
return ".jpg"
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func (h *Web) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID)})
|
||||
views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin)})
|
||||
}
|
||||
h.page(w, r, "bookmarks", "bookmarks", "Bookmarks", views)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ func (h *Web) handleCoverArt(w http.ResponseWriter, r *http.Request) {
|
||||
// row, extracting embedded artwork to a temp file on demand. isTemp
|
||||
// tells the caller whether it must remove the returned path afterward.
|
||||
func (h *Web) resolveCoverArtSource(ctx context.Context, art *database.CoverArt) (path, contentHash string, isTemp bool, err error) {
|
||||
if art.Source == "folder" {
|
||||
if art.Source == "folder" || art.Source == "external" {
|
||||
// "external" is a locally-downloaded artist photo or an
|
||||
// admin-uploaded/URL-sourced cover override — already a plain
|
||||
// file on disk, same as a folder image.
|
||||
return art.SourcePath, art.ContentHash, false, nil
|
||||
}
|
||||
|
||||
|
||||
+12
-5
@@ -75,6 +75,7 @@ func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.maybeFetchArtistPhoto(artist)
|
||||
albums, err := database.ListAlbumsByArtist(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -85,9 +86,9 @@ func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
|
||||
views = append(views, toAlbumView(h.DB, a))
|
||||
}
|
||||
h.page(w, r, "artist", "artists", artist.Name, struct {
|
||||
Artist *database.Artist
|
||||
Artist ArtistView
|
||||
Albums []AlbumView
|
||||
}{artist, views})
|
||||
}{toArtistView(artist), views})
|
||||
}
|
||||
|
||||
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -126,12 +127,14 @@ func (h *Web) handleAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
userID := ""
|
||||
isAdmin := false
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
isAdmin = user.IsAdmin
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, album, userID))
|
||||
rows = append(rows, toSongRow(h.DB, s, album, userID, isAdmin))
|
||||
}
|
||||
starred := false
|
||||
if user != nil {
|
||||
@@ -157,12 +160,14 @@ func (h *Web) handleSongs(w http.ResponseWriter, r *http.Request) {
|
||||
songs = songs[:pageSize]
|
||||
}
|
||||
userID := ""
|
||||
isAdmin := false
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
isAdmin = user.IsAdmin
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID))
|
||||
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID, isAdmin))
|
||||
}
|
||||
h.page(w, r, "songs", "songs", "Songs", struct {
|
||||
Songs []SongRow
|
||||
@@ -219,14 +224,16 @@ func (h *Web) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if songIDRows != nil {
|
||||
defer songIDRows.Close()
|
||||
userID := ""
|
||||
isAdmin := false
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
isAdmin = user.IsAdmin
|
||||
}
|
||||
for songIDRows.Next() {
|
||||
var id string
|
||||
if songIDRows.Scan(&id) == nil {
|
||||
if song, err := database.GetSong(h.DB, id); err == nil {
|
||||
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID))
|
||||
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID, isAdmin))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (h *Web) handlePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin))
|
||||
}
|
||||
h.page(w, r, "playlist", "playlists", playlist.Name, struct {
|
||||
Playlist *database.Playlist
|
||||
@@ -109,7 +109,7 @@ func (h *Web) handleFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
var songs []SongRow
|
||||
for _, id := range songIDs {
|
||||
if s, err := database.GetSong(h.DB, id); err == nil {
|
||||
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID))
|
||||
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID, user.IsAdmin))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func (h *Web) handleRecent(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin))
|
||||
}
|
||||
h.page(w, r, "recent", "recent", "Recently Played", rows)
|
||||
}
|
||||
|
||||
@@ -34,3 +34,25 @@ func (h *Web) handlePodcastDownload(w http.ResponseWriter, r *http.Request) {
|
||||
go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }()
|
||||
http.Redirect(w, r, "/podcasts", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handlePodcastDownloadAll(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetPodcastChannel(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := h.Podcasts.DownloadChannel(context.Background(), id); err != nil && h.Log != nil {
|
||||
h.Log.Warn("podcast channel download failed", "channel", id, "err", err)
|
||||
}
|
||||
}()
|
||||
destination := "/podcasts"
|
||||
if next := r.FormValue("next"); next != "" {
|
||||
destination = safeRedirectPath(next)
|
||||
}
|
||||
http.Redirect(w, r, destination, http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func (h *Web) NewRouter() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /podcasts", h.requireUser(h.handlePodcasts))
|
||||
mux.HandleFunc("POST /podcasts/{id}/download", h.requireUser(h.handlePodcastDownload))
|
||||
mux.HandleFunc("POST /podcasts/{id}/download-all", h.requireUser(h.handlePodcastDownloadAll))
|
||||
|
||||
mux.HandleFunc("GET /radio", h.requireUser(h.handleRadio))
|
||||
mux.HandleFunc("POST /radio", h.requireAdmin(h.handleRadioCreate))
|
||||
@@ -92,6 +93,7 @@ func (h *Web) NewRouter() http.Handler {
|
||||
mux.HandleFunc("GET /admin/scanner", h.requireAdmin(h.handleAdminScanner))
|
||||
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
|
||||
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
|
||||
mux.HandleFunc("POST /admin/settings/artist-photos/refresh", h.requireAdmin(h.handleAdminArtistPhotosRefresh))
|
||||
|
||||
mux.HandleFunc("GET /admin/podcasts", h.requireAdmin(h.handleAdminPodcasts))
|
||||
mux.HandleFunc("POST /admin/podcasts", h.requireAdmin(h.handleAdminPodcastCreate))
|
||||
@@ -100,6 +102,13 @@ func (h *Web) NewRouter() http.Handler {
|
||||
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload))
|
||||
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete))
|
||||
|
||||
mux.HandleFunc("GET /admin/artists/{id}/edit", h.requireAdmin(h.handleAdminArtistEdit))
|
||||
mux.HandleFunc("POST /admin/artists/{id}/edit", h.requireAdmin(h.handleAdminArtistUpdate))
|
||||
mux.HandleFunc("GET /admin/albums/{id}/edit", h.requireAdmin(h.handleAdminAlbumEdit))
|
||||
mux.HandleFunc("POST /admin/albums/{id}/edit", h.requireAdmin(h.handleAdminAlbumUpdate))
|
||||
mux.HandleFunc("GET /admin/songs/{id}/edit", h.requireAdmin(h.handleAdminSongEdit))
|
||||
mux.HandleFunc("POST /admin/songs/{id}/edit", h.requireAdmin(h.handleAdminSongUpdate))
|
||||
|
||||
mux.HandleFunc("GET /admin/api-keys", h.requireAdmin(h.handleAdminAPIKeys))
|
||||
mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke))
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ func (h *Web) handleSharePage(w http.ResponseWriter, r *http.Request) {
|
||||
if serr != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), ""))
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), "", false))
|
||||
}
|
||||
|
||||
owner, _ := database.GetUserByID(h.DB, share.OwnerID)
|
||||
|
||||
+12
-3
@@ -12,6 +12,8 @@ import (
|
||||
type ArtistView struct {
|
||||
ID string
|
||||
Name string
|
||||
Bio string
|
||||
CoverArtID string
|
||||
}
|
||||
|
||||
type AlbumView struct {
|
||||
@@ -32,10 +34,15 @@ type SongRow struct {
|
||||
TrackNumber int64
|
||||
DurationSeconds float64
|
||||
Starred bool
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
func toArtistView(a *database.Artist) ArtistView {
|
||||
return ArtistView{ID: a.ID, Name: a.Name}
|
||||
v := ArtistView{ID: a.ID, Name: a.Name, Bio: a.Bio}
|
||||
if a.CoverArtID.Valid {
|
||||
v.CoverArtID = a.CoverArtID.String
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
||||
@@ -56,12 +63,14 @@ func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
||||
|
||||
// toSongRow builds a display row for a song, given its owning album (may
|
||||
// be nil for albumless tracks) and the current user (for the starred
|
||||
// flag; pass "" userID to skip that lookup).
|
||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string) SongRow {
|
||||
// flag; pass "" userID to skip that lookup, and isAdmin=false to hide
|
||||
// the row's "Edit" link).
|
||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string, isAdmin bool) SongRow {
|
||||
row := SongRow{
|
||||
ID: s.ID,
|
||||
Title: s.Title,
|
||||
DurationSeconds: 0,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
if s.TrackNumber.Valid {
|
||||
row.TrackNumber = s.TrackNumber.Int64
|
||||
|
||||
@@ -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;
|
||||
@@ -178,6 +178,21 @@ body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vs-song-art-cell {
|
||||
width: 48px;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.vs-song-art {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.25rem;
|
||||
object-fit: cover;
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
/* --- Player bar --- */
|
||||
|
||||
.vs-player {
|
||||
@@ -352,3 +367,9 @@ body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* Inline page transitions keep the persistent player mounted. */
|
||||
.vs-main[aria-busy="true"] {
|
||||
opacity: .65;
|
||||
transition: opacity 120ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
+129
-1
@@ -474,7 +474,134 @@
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.requestSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Inline navigation ---
|
||||
// Fetch server-rendered pages and replace only the main content area. The
|
||||
// audio element and player live outside that area, so playback continues
|
||||
// uninterrupted while the URL, title, and active sidebar item still track
|
||||
// the current page normally.
|
||||
function initInlineNavigation() {
|
||||
var pending = null;
|
||||
var currentURL = location.href;
|
||||
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
|
||||
|
||||
function isLocalHTTP(url) {
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && url.origin === location.origin;
|
||||
}
|
||||
|
||||
function shouldHandleLink(link, ev) {
|
||||
if (!link || ev.defaultPrevented || ev.button !== 0) return false;
|
||||
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return false;
|
||||
if (link.target || link.hasAttribute("download") || link.dataset.noInline != null) return false;
|
||||
var url = new URL(link.href, location.href);
|
||||
if (!isLocalHTTP(url)) return false;
|
||||
if (url.pathname.indexOf("/static/") === 0 || url.pathname.indexOf("/stream/") === 0 ||
|
||||
url.pathname.indexOf("/cover-art/") === 0 || url.pathname.indexOf("/share/") === 0 ||
|
||||
/\/export$/.test(url.pathname)) return false;
|
||||
// Let the browser handle an in-page fragment without a network request.
|
||||
if (url.pathname === location.pathname && url.search === location.search && url.hash) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
var main = document.querySelector(".vs-main");
|
||||
if (!main) return;
|
||||
if (busy) main.setAttribute("aria-busy", "true");
|
||||
else main.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
function applyPage(html, responseURL, push, restoreScroll) {
|
||||
var parsed = new DOMParser().parseFromString(html, "text/html");
|
||||
var nextMain = parsed.querySelector(".vs-main");
|
||||
var oldMain = document.querySelector(".vs-main");
|
||||
// Login, setup, shared, and error documents are intentionally full-page.
|
||||
if (!nextMain || !oldMain) return false;
|
||||
|
||||
oldMain.replaceWith(nextMain);
|
||||
document.title = parsed.title || document.title;
|
||||
var tokenScript = parsed.querySelector("script[data-vs-csrf]");
|
||||
if (tokenScript) window.vsCSRFToken = tokenScript.dataset.vsCsrf;
|
||||
|
||||
var activeHref = parsed.querySelector(".vs-sidebar .nav-link.active");
|
||||
document.querySelectorAll(".vs-sidebar .nav-link").forEach(function (link) {
|
||||
link.classList.toggle("active", !!activeHref && link.getAttribute("href") === activeHref.getAttribute("href"));
|
||||
});
|
||||
|
||||
currentURL = responseURL;
|
||||
if (push) history.pushState({ vsInline: true, scrollY: 0 }, "", responseURL);
|
||||
var top = typeof restoreScroll === "number" ? restoreScroll : 0;
|
||||
window.scrollTo(0, top);
|
||||
var autofocus = nextMain.querySelector("[autofocus]");
|
||||
if (autofocus) autofocus.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function navigate(url, options) {
|
||||
options = options || {};
|
||||
if (pending) pending.abort();
|
||||
var controller = new AbortController();
|
||||
pending = controller;
|
||||
setBusy(true);
|
||||
var request = {
|
||||
method: options.method || "GET",
|
||||
body: options.body || null,
|
||||
signal: controller.signal,
|
||||
headers: { "X-VibeSonic-Inline": "1" },
|
||||
};
|
||||
return fetch(url, request).then(function (response) {
|
||||
if (!response.ok || !(response.headers.get("Content-Type") || "").includes("text/html")) {
|
||||
throw new Error("not an inline page");
|
||||
}
|
||||
return response.text().then(function (html) {
|
||||
if (!applyPage(html, response.url, options.push !== false, options.restoreScroll)) {
|
||||
location.assign(response.url);
|
||||
}
|
||||
});
|
||||
}).catch(function (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
if ((options.method || "GET") === "GET") location.assign(url);
|
||||
else location.reload();
|
||||
}
|
||||
}).finally(function () {
|
||||
if (pending === controller) {
|
||||
pending = null;
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var link = ev.target.closest("a[href]");
|
||||
if (!shouldHandleLink(link, ev)) return;
|
||||
ev.preventDefault();
|
||||
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
|
||||
navigate(link.href);
|
||||
});
|
||||
|
||||
document.addEventListener("submit", function (ev) {
|
||||
var form = ev.target;
|
||||
if (ev.defaultPrevented || !(form instanceof HTMLFormElement) || form.dataset.noInline != null) return;
|
||||
var url = new URL(form.action || location.href, location.href);
|
||||
if (!isLocalHTTP(url) || url.pathname === "/logout") return;
|
||||
ev.preventDefault();
|
||||
var data = new FormData(form);
|
||||
var method = (form.method || "GET").toUpperCase();
|
||||
if (method === "GET") {
|
||||
url.search = new URLSearchParams(data).toString();
|
||||
navigate(url.href);
|
||||
} else {
|
||||
navigate(url.href, { method: method, body: data });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", function (ev) {
|
||||
navigate(location.href, {
|
||||
push: false,
|
||||
restoreScroll: ev.state && typeof ev.state.scrollY === "number" ? ev.state.scrollY : 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -483,5 +610,6 @@
|
||||
var player = initPlayer();
|
||||
initKeyboardShortcuts(player);
|
||||
initStarButtons();
|
||||
initInlineNavigation();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -22,6 +22,13 @@
|
||||
{{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}}
|
||||
</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">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
|
||||
|
||||
@@ -21,6 +21,43 @@
|
||||
</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-body">
|
||||
<h2 class="h6">External authentication</h2>
|
||||
|
||||
@@ -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}}
|
||||
@@ -11,6 +11,9 @@
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
|
||||
</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>
|
||||
{{template "song_rows" .Data.Songs}}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{{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">
|
||||
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
{{define "artist_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>
|
||||
</a>
|
||||
{{end}}
|
||||
@@ -26,11 +26,17 @@
|
||||
data-cover="{{$s.CoverArtID}}"
|
||||
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-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>
|
||||
<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}}
|
||||
</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>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</script>
|
||||
</head>
|
||||
<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">
|
||||
<a href="/" class="vs-brand d-flex align-items-center gap-2">
|
||||
<i class="bi bi-vinyl-fill"></i>
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
{{range .Data}}
|
||||
<div class="card mb-3">
|
||||
<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>
|
||||
<table class="table table-sm vs-table mb-0">
|
||||
<tbody>
|
||||
|
||||
Reference in New Issue
Block a user