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_MPV_PATH` | `mpv` (from `PATH`) | Path to the `mpv` binary, used as the Subsonic jukebox's playback backend. If not found, the jukebox is administratively unavailable and `jukeboxControl` returns a clear error instead of a fake success. |
|
||||||
| `VIBESONIC_SCAN_INTERVAL` | `1h` | How often to rescan enabled music folders, as a Go duration (`30m`, `6h`, ...). A startup scan always runs regardless of this value. |
|
| `VIBESONIC_SCAN_INTERVAL` | `1h` | How often to rescan enabled music folders, as a Go duration (`30m`, `6h`, ...). A startup scan always runs regardless of this value. |
|
||||||
| `VIBESONIC_SESSION_SECRET` | *(none)* | Reserved for future use hardening session cookies beyond their database-backed random IDs. Not required to run, but recommended to set (e.g. `openssl rand -hex 32`) — VibeSonic logs a warning at startup if it's empty. |
|
| `VIBESONIC_SESSION_SECRET` | *(none)* | Reserved for future use hardening session cookies beyond their database-backed random IDs. Not required to run, but recommended to set (e.g. `openssl rand -hex 32`) — VibeSonic logs a warning at startup if it's empty. |
|
||||||
| `VIBESONIC_COOKIE_SECURE` | `true` | Whether session/CSRF cookies get the `Secure` attribute. Set to `false` only for local development over plain HTTP. |
|
| `VIBESONIC_COOKIE_SECURE` | `false` | Whether session/CSRF cookies get the `Secure` attribute. Set to `true` when VibeSonic is served over HTTPS (including through a TLS-terminating reverse proxy); leave it off for direct plain-HTTP access. |
|
||||||
| `VIBESONIC_ENABLE_EXTERNAL_METADATA` | `false` | Enable optional MusicBrainz/Cover Art Archive lookups for songs/albums that already carry a locally-tagged MusicBrainz ID. VibeSonic works fully offline with this off; it's the only feature that makes outbound Internet requests. |
|
| `VIBESONIC_ENABLE_EXTERNAL_METADATA` | `false` | Enable optional MusicBrainz/Cover Art Archive lookups for songs/albums that already carry a locally-tagged MusicBrainz ID. VibeSonic works fully offline with this off; it's the only feature that makes outbound Internet requests. |
|
||||||
|
| `VIBESONIC_ENABLE_ARTIST_PHOTOS` | `false` | Enable auto-fetching an artist photo for every artist with no manually-set one: fanart.tv (only when both `VIBESONIC_FANART_TV_API_KEY` is set and the artist has a tagged MusicBrainz ID), falling back to a direct name search against Wikidata/Wikimedia Commons — this never calls the MusicBrainz API itself. Off by default for the same offline-first reason as `VIBESONIC_ENABLE_EXTERNAL_METADATA`. An admin can always set a photo manually from an artist's edit page regardless of this setting, and can trigger a one-off bulk fetch (with per-artist results, including any errors) from Admin → System Settings regardless of this toggle. |
|
||||||
|
| `VIBESONIC_FANART_TV_API_KEY` | *(none)* | Optional personal [fanart.tv](https://fanart.tv/) API key, tried first (for artists with a tagged MusicBrainz ID) when `VIBESONIC_ENABLE_ARTIST_PHOTOS` is on. Leave unset to go straight to the Wikidata name search. |
|
||||||
| `VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS` | `false` | Allow podcast feed/enclosure URLs to resolve to loopback/LAN/link-local addresses. Leave this off unless you intentionally host podcast feeds on a private network — see [`docs/security.md`](security.md#podcast-fetching-ssrf-protections). |
|
| `VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS` | `false` | Allow podcast feed/enclosure URLs to resolve to loopback/LAN/link-local addresses. Leave this off unless you intentionally host podcast feeds on a private network — see [`docs/security.md`](security.md#podcast-fetching-ssrf-protections). |
|
||||||
|
|
||||||
## External authentication
|
## External authentication
|
||||||
|
|||||||
+29
-2
@@ -21,8 +21,9 @@ authentication model in detail.
|
|||||||
comparison itself uses a constant-time compare
|
comparison itself uses a constant-time compare
|
||||||
(`crypto/subtle.ConstantTimeCompare`) to avoid a timing side-channel.
|
(`crypto/subtle.ConstantTimeCompare`) to avoid a timing side-channel.
|
||||||
- **API keys** are stored and looked up by hash, never in plaintext.
|
- **API keys** are stored and looked up by hash, never in plaintext.
|
||||||
- **Sessions** are random opaque tokens in an HTTP-only, `Secure`
|
- **Sessions** are random opaque tokens in an HTTP-only cookie. Set
|
||||||
(configurable via `VIBESONIC_COOKIE_SECURE`, on by default) cookie.
|
`VIBESONIC_COOKIE_SECURE=true` for HTTPS deployments so browsers only send
|
||||||
|
session and CSRF cookies over secure connections.
|
||||||
- **External (trusted-proxy) authentication** is off by default and,
|
- **External (trusted-proxy) authentication** is off by default and,
|
||||||
when enabled, trusts an identity-asserting header only from
|
when enabled, trusts an identity-asserting header only from
|
||||||
configured source IPs verified against the real TCP peer address —
|
configured source IPs verified against the real TCP peer address —
|
||||||
@@ -90,6 +91,32 @@ before the full image is decoded into memory, rejecting anything over
|
|||||||
40 megapixels. This prevents a small, highly-compressed image file from
|
40 megapixels. This prevents a small, highly-compressed image file from
|
||||||
forcing an out-of-proportion memory allocation ("decompression bomb").
|
forcing an out-of-proportion memory allocation ("decompression bomb").
|
||||||
|
|
||||||
|
## Artist photos and manual metadata editing
|
||||||
|
|
||||||
|
When `VIBESONIC_ENABLE_ARTIST_PHOTOS` is on, VibeSonic looks up an
|
||||||
|
artist photo from fanart.tv (only for an artist with both a tagged
|
||||||
|
MusicBrainz ID and `VIBESONIC_FANART_TV_API_KEY` set) or, failing
|
||||||
|
that, by searching Wikidata directly by artist name and reading the
|
||||||
|
matched entity's image from Wikimedia Commons — both
|
||||||
|
externally-hosted services, same trust model as the existing
|
||||||
|
MusicBrainz/Cover Art Archive lookups used elsewhere. This path
|
||||||
|
deliberately never calls the MusicBrainz API itself (its public
|
||||||
|
instance proved too rate-limited to depend on); a name-only match is
|
||||||
|
necessarily less precise, so it requires an exact (case-insensitive)
|
||||||
|
label match and only weakly prefers a music-flavored Wikidata
|
||||||
|
description, rather than fabricating a stricter guarantee it can't
|
||||||
|
actually make. The resulting image download, and any admin-supplied
|
||||||
|
photo/cover-art URL entered on an artist or album's edit page, is
|
||||||
|
fetched through the same `internal/netguard` SSRF-safe client described
|
||||||
|
above (dial-time IP validation, capped redirects, size-limited
|
||||||
|
response). Uploaded photo/cover files are validated by actually
|
||||||
|
decoding them as an image before being written to disk.
|
||||||
|
|
||||||
|
Editing artist/album/song metadata (`/admin/artists/{id}/edit`,
|
||||||
|
`/admin/albums/{id}/edit`, `/admin/songs/{id}/edit`) and overriding a
|
||||||
|
photo/cover are admin-only actions, gated the same way as every other
|
||||||
|
`/admin/*` route.
|
||||||
|
|
||||||
## Transcoding
|
## Transcoding
|
||||||
|
|
||||||
`ffmpeg`/`ffprobe` are invoked with `exec.CommandContext` and an
|
`ffmpeg`/`ffprobe` are invoked with `exec.CommandContext` and an
|
||||||
|
|||||||
@@ -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.
|
// to the outside world.
|
||||||
EnableExternalMetadata bool
|
EnableExternalMetadata bool
|
||||||
|
|
||||||
|
// EnableArtistPhotos opts into looking up an artist photo (fanart.tv,
|
||||||
|
// falling back to Wikidata/Wikimedia Commons) for artists that carry
|
||||||
|
// a locally-tagged MusicBrainz ID. Off by default for the same
|
||||||
|
// offline-first reason as EnableExternalMetadata.
|
||||||
|
EnableArtistPhotos bool
|
||||||
|
|
||||||
|
// FanartTVAPIKey is an optional personal API key for fanart.tv,
|
||||||
|
// tried before the free Wikidata fallback when EnableArtistPhotos is
|
||||||
|
// on. Empty means "skip fanart.tv".
|
||||||
|
FanartTVAPIKey string
|
||||||
|
|
||||||
// PodcastsAllowPrivateNetworks disables SSRF protection on podcast
|
// PodcastsAllowPrivateNetworks disables SSRF protection on podcast
|
||||||
// feed/enclosure fetching, allowing feeds hosted on loopback/LAN/
|
// feed/enclosure fetching, allowing feeds hosted on loopback/LAN/
|
||||||
// link-local addresses. Off by default: podcast URLs are supplied
|
// link-local addresses. Off by default: podcast URLs are supplied
|
||||||
@@ -68,10 +79,16 @@ func Load() (Config, error) {
|
|||||||
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
|
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
|
||||||
MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"),
|
MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"),
|
||||||
SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"),
|
SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"),
|
||||||
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true),
|
// The built-in server speaks plain HTTP, so Secure cookies cannot be
|
||||||
|
// the default: browsers would discard them during first-run setup.
|
||||||
|
// Deployments terminating TLS at a reverse proxy should opt in.
|
||||||
|
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", false),
|
||||||
|
|
||||||
EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false),
|
EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false),
|
||||||
|
|
||||||
|
EnableArtistPhotos: envBool("VIBESONIC_ENABLE_ARTIST_PHOTOS", false),
|
||||||
|
FanartTVAPIKey: os.Getenv("VIBESONIC_FANART_TV_API_KEY"),
|
||||||
|
|
||||||
PodcastsAllowPrivateNetworks: envBool("VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS", false),
|
PodcastsAllowPrivateNetworks: envBool("VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS", false),
|
||||||
}
|
}
|
||||||
cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db"))
|
cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db"))
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
if cfg.DBPath != "/var/lib/vibesonic/vibesonic.db" {
|
if cfg.DBPath != "/var/lib/vibesonic/vibesonic.db" {
|
||||||
t.Errorf("DBPath = %q, want default under DataDir", cfg.DBPath)
|
t.Errorf("DBPath = %q, want default under DataDir", cfg.DBPath)
|
||||||
}
|
}
|
||||||
|
if cfg.CookieSecure {
|
||||||
|
t.Error("CookieSecure = true, want false for the default plain-HTTP server")
|
||||||
|
}
|
||||||
if cfg.ExtAuth.Enabled {
|
if cfg.ExtAuth.Enabled {
|
||||||
t.Errorf("ExtAuth.Enabled = true, want false by default")
|
t.Errorf("ExtAuth.Enabled = true, want false by default")
|
||||||
}
|
}
|
||||||
@@ -33,6 +36,17 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadSecureCookiesOptIn(t *testing.T) {
|
||||||
|
t.Setenv("VIBESONIC_COOKIE_SECURE", "true")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.CookieSecure {
|
||||||
|
t.Error("CookieSecure = false, want true when explicitly enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadMusicDirs(t *testing.T) {
|
func TestLoadMusicDirs(t *testing.T) {
|
||||||
withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "})
|
withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "})
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ func (s *Store) ExtractEmbedded(ctx context.Context, mediaPath string, streamInd
|
|||||||
"-map", fmt.Sprintf("0:%d", streamIndex),
|
"-map", fmt.Sprintf("0:%d", streamIndex),
|
||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-c:v", "copy",
|
"-c:v", "copy",
|
||||||
"--", tmp.Name(),
|
// Temporary files deliberately have a neutral .img suffix. Tell
|
||||||
|
// ffmpeg which muxer to use instead of asking it to infer one from
|
||||||
|
// that suffix. ffmpeg does not accept `--` as an end-of-options
|
||||||
|
// marker, so including it here made every embedded-art request fail.
|
||||||
|
"-f", "image2", tmp.Name(),
|
||||||
)
|
)
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
os.Remove(tmp.Name())
|
os.Remove(tmp.Name())
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package coverart
|
package coverart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -9,6 +10,33 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestExtractEmbeddedSuppliesExplicitOutputFormat(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
argsPath := filepath.Join(dir, "args")
|
||||||
|
fakeFFmpeg := filepath.Join(dir, "ffmpeg")
|
||||||
|
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGS_PATH\"\n"
|
||||||
|
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("ARGS_PATH", argsPath)
|
||||||
|
|
||||||
|
store := New(dir, fakeFFmpeg)
|
||||||
|
path, err := store.ExtractEmbedded(context.Background(), filepath.Join(dir, "song.mp3"), 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractEmbedded: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
args, err := os.ReadFile(argsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(args)
|
||||||
|
if got != "-y\n-v\nerror\n-i\n"+filepath.Join(dir, "song.mp3")+"\n-map\n0:2\n-frames:v\n1\n-c:v\ncopy\n-f\nimage2\n"+path+"\n" {
|
||||||
|
t.Fatalf("unexpected ffmpeg arguments:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeTestJPEG(t *testing.T, path string, w, h int) {
|
func writeTestJPEG(t *testing.T, path string, w, h int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type CoverArt struct {
|
|||||||
ID string
|
ID string
|
||||||
AlbumID sql.NullString
|
AlbumID sql.NullString
|
||||||
MediaFileID sql.NullString
|
MediaFileID sql.NullString
|
||||||
|
ArtistID sql.NullString
|
||||||
Source string
|
Source string
|
||||||
SourcePath string
|
SourcePath string
|
||||||
MimeType string
|
MimeType string
|
||||||
@@ -19,7 +20,7 @@ type CoverArt struct {
|
|||||||
Height int
|
Height int
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverArtSelect = `SELECT id, album_id, media_file_id, source, source_path, mime_type, content_hash, width, height FROM cover_art`
|
const coverArtSelect = `SELECT id, album_id, media_file_id, artist_id, source, source_path, mime_type, content_hash, width, height FROM cover_art`
|
||||||
|
|
||||||
func GetCoverArt(db *sql.DB, id string) (*CoverArt, error) {
|
func GetCoverArt(db *sql.DB, id string) (*CoverArt, error) {
|
||||||
return scanCoverArt(db.QueryRow(coverArtSelect+` WHERE id = ?`, id))
|
return scanCoverArt(db.QueryRow(coverArtSelect+` WHERE id = ?`, id))
|
||||||
@@ -31,7 +32,7 @@ func GetCoverArtForAlbum(db *sql.DB, albumID string) (*CoverArt, error) {
|
|||||||
|
|
||||||
func scanCoverArt(row *sql.Row) (*CoverArt, error) {
|
func scanCoverArt(row *sql.Row) (*CoverArt, error) {
|
||||||
var c CoverArt
|
var c CoverArt
|
||||||
err := row.Scan(&c.ID, &c.AlbumID, &c.MediaFileID, &c.Source, &c.SourcePath, &c.MimeType, &c.ContentHash, &c.Width, &c.Height)
|
err := row.Scan(&c.ID, &c.AlbumID, &c.MediaFileID, &c.ArtistID, &c.Source, &c.SourcePath, &c.MimeType, &c.ContentHash, &c.Width, &c.Height)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,36 @@ func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mediaFileID, m
|
|||||||
return GetCoverArt(db, id)
|
return GetCoverArt(db, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpsertArtistCoverArt records (or replaces) an artist's photo and links
|
||||||
|
// it as the artist's cover art. manual=true marks it as an admin
|
||||||
|
// override (see database.SetArtistCoverArt); manual=false is the
|
||||||
|
// auto-fetch path, which never overwrites an existing manual override.
|
||||||
|
func UpsertArtistCoverArt(db *sql.DB, artistID, source, sourcePath, mimeType, contentHash string, width, height int, manual bool) (*CoverArt, error) {
|
||||||
|
var id string
|
||||||
|
err := db.QueryRow(`SELECT id FROM cover_art WHERE artist_id = ?`, artistID).Scan(&id)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
_, err = db.Exec(`UPDATE cover_art SET source=?, source_path=?, mime_type=?, content_hash=?, width=?, height=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||||
|
source, sourcePath, mimeType, contentHash, width, height, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
id = idgen.New("ca")
|
||||||
|
_, err = db.Exec(`INSERT INTO cover_art (id, artist_id, source, source_path, mime_type, content_hash, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, artistID, source, sourcePath, mimeType, contentHash, width, height)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := SetArtistCoverArt(db, artistID, sql.NullString{String: id, Valid: true}, manual); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return GetCoverArt(db, id)
|
||||||
|
}
|
||||||
|
|
||||||
func nullEmptyStr(s string) any {
|
func nullEmptyStr(s string) any {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ type Artist struct {
|
|||||||
Name string
|
Name string
|
||||||
SortName string
|
SortName string
|
||||||
MusicBrainzID string
|
MusicBrainzID string
|
||||||
|
Bio string
|
||||||
|
CoverArtID sql.NullString
|
||||||
|
PhotoManual bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const artistSelect = `SELECT id, name, sort_name, musicbrainz_id, bio, cover_art_id, photo_manual FROM artists`
|
||||||
|
|
||||||
|
func scanArtist(row *sql.Row) (*Artist, error) {
|
||||||
|
var a Artist
|
||||||
|
err := row.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindOrCreateArtist looks up an artist by (name, musicbrainz_id) and
|
// FindOrCreateArtist looks up an artist by (name, musicbrainz_id) and
|
||||||
@@ -43,19 +60,11 @@ func FindOrCreateArtist(db *sql.DB, name, sortName, musicBrainzID string) (*Arti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetArtist(db *sql.DB, id string) (*Artist, error) {
|
func GetArtist(db *sql.DB, id string) (*Artist, error) {
|
||||||
var a Artist
|
return scanArtist(db.QueryRow(artistSelect+` WHERE id = ?`, id))
|
||||||
err := db.QueryRow(`SELECT id, name, sort_name, musicbrainz_id FROM artists WHERE id = ?`, id).Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &a, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListArtists(db *sql.DB) ([]*Artist, error) {
|
func ListArtists(db *sql.DB) ([]*Artist, error) {
|
||||||
rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id FROM artists a
|
rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id, a.bio, a.cover_art_id, a.photo_manual FROM artists a
|
||||||
JOIN album_artists aa ON aa.artist_id = a.id
|
JOIN album_artists aa ON aa.artist_id = a.id
|
||||||
ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`)
|
ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,7 +74,7 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
|
|||||||
var out []*Artist
|
var out []*Artist
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a Artist
|
var a Artist
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID); err != nil {
|
if err := rows.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, &a)
|
out = append(out, &a)
|
||||||
@@ -73,6 +82,25 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateArtist overwrites an artist's name, sort name, and bio. It never
|
||||||
|
// touches the artist's photo (see SetArtistCoverArt).
|
||||||
|
func UpdateArtist(db *sql.DB, id, name, sortName, bio string) error {
|
||||||
|
_, err := db.Exec(`UPDATE artists SET name = ?, sort_name = ?, bio = ? WHERE id = ?`, name, sortName, bio, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetArtistCoverArt links (or unlinks) an artist's cover_art row.
|
||||||
|
// manual=true records an admin override; manual=false is the auto-fetch
|
||||||
|
// path, which is guarded to never overwrite an existing manual override.
|
||||||
|
func SetArtistCoverArt(db *sql.DB, artistID string, coverArtID sql.NullString, manual bool) error {
|
||||||
|
if manual {
|
||||||
|
_, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 1 WHERE id = ?`, nullStr(coverArtID), artistID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 0 WHERE id = ? AND photo_manual = 0`, nullStr(coverArtID), artistID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// --- Genres ---
|
// --- Genres ---
|
||||||
|
|
||||||
type Genre struct {
|
type Genre struct {
|
||||||
@@ -223,6 +251,14 @@ func scanAlbums(rows *sql.Rows) ([]*Album, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAlbum overwrites an album's editable metadata fields. It never
|
||||||
|
// touches the album's cover art (see SetAlbumCoverArt).
|
||||||
|
func UpdateAlbum(db *sql.DB, id, title, sortTitle string, year, originalYear sql.NullInt64, label string) error {
|
||||||
|
_, err := db.Exec(`UPDATE albums SET title = ?, sort_title = ?, year = ?, original_year = ?, record_label = ? WHERE id = ?`,
|
||||||
|
title, sortTitle, nullInt(year), nullInt(originalYear), label, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func SetAlbumCoverArt(db *sql.DB, albumID, coverArtID string) error {
|
func SetAlbumCoverArt(db *sql.DB, albumID, coverArtID string) error {
|
||||||
_, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID)
|
_, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID)
|
||||||
return err
|
return err
|
||||||
@@ -260,11 +296,12 @@ type Song struct {
|
|||||||
ReplayGainTrackPeak sql.NullFloat64
|
ReplayGainTrackPeak sql.NullFloat64
|
||||||
ReplayGainAlbumGainDB sql.NullFloat64
|
ReplayGainAlbumGainDB sql.NullFloat64
|
||||||
ReplayGainAlbumPeak sql.NullFloat64
|
ReplayGainAlbumPeak sql.NullFloat64
|
||||||
|
MetadataLocked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
const songSelect = `SELECT id, media_file_id, album_id, title, sort_title, track_number, track_total, disc_number, disc_total,
|
const songSelect = `SELECT id, media_file_id, album_id, title, sort_title, track_number, track_total, disc_number, disc_total,
|
||||||
composer, conductor, isrc, bpm, musicbrainz_track_id, musicbrainz_recording_id,
|
composer, conductor, isrc, bpm, musicbrainz_track_id, musicbrainz_recording_id,
|
||||||
replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak FROM songs`
|
replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak, metadata_locked FROM songs`
|
||||||
|
|
||||||
func GetSong(db *sql.DB, id string) (*Song, error) {
|
func GetSong(db *sql.DB, id string) (*Song, error) {
|
||||||
return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id))
|
return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id))
|
||||||
@@ -278,7 +315,7 @@ func scanSong(row *sql.Row) (*Song, error) {
|
|||||||
var s Song
|
var s Song
|
||||||
err := row.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal,
|
err := row.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal,
|
||||||
&s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID,
|
&s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID,
|
||||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak)
|
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -312,7 +349,7 @@ func scanSongs(rows *sql.Rows) ([]*Song, error) {
|
|||||||
var s Song
|
var s Song
|
||||||
if err := rows.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal,
|
if err := rows.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal,
|
||||||
&s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID,
|
&s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID,
|
||||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak); err != nil {
|
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, &s)
|
out = append(out, &s)
|
||||||
@@ -354,14 +391,29 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
_, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?,
|
if existing.MetadataLocked {
|
||||||
composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
// An admin has hand-edited title/sort_title/track_number/
|
||||||
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?,
|
// disc_number/composer/conductor for this song; leave those
|
||||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
// alone and only refresh the fields that come straight off
|
||||||
nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle,
|
// the file itself and were never exposed for editing.
|
||||||
in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID,
|
_, err = db.Exec(`UPDATE songs SET album_id=?, track_total=?, disc_total=?, disc_subtitle=?,
|
||||||
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak),
|
comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
||||||
existing.ID)
|
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?,
|
||||||
|
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||||
|
nullStr(in.AlbumID), nullInt(in.TrackTotal), nullInt(in.DiscTotal), in.DiscSubtitle,
|
||||||
|
in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID,
|
||||||
|
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak),
|
||||||
|
existing.ID)
|
||||||
|
} else {
|
||||||
|
_, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?,
|
||||||
|
composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
||||||
|
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?,
|
||||||
|
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||||
|
nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle,
|
||||||
|
in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID,
|
||||||
|
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak),
|
||||||
|
existing.ID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -382,6 +434,15 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
|
|||||||
return GetSong(db, id)
|
return GetSong(db, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateSongMetadata overwrites a song's editable fields. locked=true
|
||||||
|
// sets metadata_locked so a later library rescan (UpsertSong) leaves
|
||||||
|
// these fields alone instead of overwriting them from the file's tags.
|
||||||
|
func UpdateSongMetadata(db *sql.DB, id, title, sortTitle string, trackNumber, discNumber sql.NullInt64, composer, conductor string, locked bool) error {
|
||||||
|
_, err := db.Exec(`UPDATE songs SET title=?, sort_title=?, track_number=?, disc_number=?, composer=?, conductor=?, metadata_locked=? WHERE id=?`,
|
||||||
|
title, sortTitle, nullInt(trackNumber), nullInt(discNumber), composer, conductor, locked, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func ClearSongArtists(db *sql.DB, songID string) error {
|
func ClearSongArtists(db *sql.DB, songID string) error {
|
||||||
_, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID)
|
_, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ func ListGenreStats(db *sql.DB) ([]GenreStat, error) {
|
|||||||
func SongsByGenre(db *sql.DB, genreName string, limit, offset int) ([]*Song, error) {
|
func SongsByGenre(db *sql.DB, genreName string, limit, offset int) ([]*Song, error) {
|
||||||
rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||||
FROM songs s
|
FROM songs s
|
||||||
JOIN song_genres sg ON sg.song_id = s.id
|
JOIN song_genres sg ON sg.song_id = s.id
|
||||||
JOIN genres g ON g.id = sg.genre_id
|
JOIN genres g ON g.id = sg.genre_id
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func FindArtistByName(db *sql.DB, name string) (*Artist, error) {
|
|||||||
func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
||||||
rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||||
FROM songs s
|
FROM songs s
|
||||||
JOIN song_artists sa ON sa.song_id = s.id
|
JOIN song_artists sa ON sa.song_id = s.id
|
||||||
LEFT JOIN (SELECT song_id, COUNT(*) AS plays FROM playback_history GROUP BY song_id) ph ON ph.song_id = s.id
|
LEFT JOIN (SELECT song_id, COUNT(*) AS plays FROM playback_history GROUP BY song_id) ph ON ph.song_id = s.id
|
||||||
@@ -58,7 +58,7 @@ func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
|||||||
func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, error) {
|
func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, error) {
|
||||||
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||||
FROM songs s
|
FROM songs s
|
||||||
JOIN song_genres sg ON sg.song_id = s.id
|
JOIN song_genres sg ON sg.song_id = s.id
|
||||||
WHERE sg.genre_id IN (SELECT genre_id FROM song_genres WHERE song_id = ?)
|
WHERE sg.genre_id IN (SELECT genre_id FROM song_genres WHERE song_id = ?)
|
||||||
@@ -78,7 +78,7 @@ func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, err
|
|||||||
func SimilarSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
func SimilarSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
||||||
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||||
FROM songs s
|
FROM songs s
|
||||||
JOIN song_genres sg ON sg.song_id = s.id
|
JOIN song_genres sg ON sg.song_id = s.id
|
||||||
WHERE sg.genre_id IN (
|
WHERE sg.genre_id IN (
|
||||||
|
|||||||
@@ -252,6 +252,30 @@ func (m *Manager) DownloadEpisode(ctx context.Context, episodeID string) error {
|
|||||||
return database.SetPodcastEpisodeMediaFile(m.DB, episodeID, mf.ID)
|
return database.SetPodcastEpisodeMediaFile(m.DB, episodeID, mf.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadChannel downloads every episode in a channel that is not already
|
||||||
|
// complete or in progress. Downloads are run in feed order and still pass
|
||||||
|
// through the manager's concurrency limiter.
|
||||||
|
func (m *Manager) DownloadChannel(ctx context.Context, channelID string) error {
|
||||||
|
episodes, err := database.ListPodcastEpisodes(m.DB, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, episode := range episodes {
|
||||||
|
if episode.Status == "completed" || episode.Status == "downloading" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.DownloadEpisode(ctx, episode.ID); err != nil {
|
||||||
|
if m.Log != nil {
|
||||||
|
m.Log.Warn("podcast episode download failed", "episode", episode.ID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string) error {
|
func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string) error {
|
||||||
if err := netguard.ValidateURL(sourceURL); err != nil {
|
if err := netguard.ValidateURL(sourceURL); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
@@ -283,10 +284,10 @@ func (h *Web) handleAdminScanner(w http.ResponseWriter, r *http.Request) {
|
|||||||
// --- System settings ---
|
// --- System settings ---
|
||||||
|
|
||||||
func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderAdminSettings(w, r, "", "")
|
h.renderAdminSettings(w, r, "", "", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string) {
|
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string, photoResults []ArtistPhotoOutcome) {
|
||||||
trusted := ""
|
trusted := ""
|
||||||
for i, p := range h.Config.ExtAuth.TrustedSources {
|
for i, p := range h.Config.ExtAuth.TrustedSources {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
@@ -301,6 +302,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
|
|||||||
ExtAuthEnabled bool
|
ExtAuthEnabled bool
|
||||||
ExtAuthTrustedSources string
|
ExtAuthTrustedSources string
|
||||||
ExtAuthHeader string
|
ExtAuthHeader string
|
||||||
|
ArtistPhotosEnabled bool
|
||||||
|
ArtistPhotoResults []ArtistPhotoOutcome
|
||||||
}{
|
}{
|
||||||
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
|
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
|
||||||
FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath),
|
FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath),
|
||||||
@@ -308,6 +311,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
|
|||||||
ExtAuthEnabled: h.Config.ExtAuth.Enabled,
|
ExtAuthEnabled: h.Config.ExtAuth.Enabled,
|
||||||
ExtAuthTrustedSources: trusted,
|
ExtAuthTrustedSources: trusted,
|
||||||
ExtAuthHeader: h.Config.ExtAuth.UserHeader,
|
ExtAuthHeader: h.Config.ExtAuth.UserHeader,
|
||||||
|
ArtistPhotosEnabled: h.Config.EnableArtistPhotos,
|
||||||
|
ArtistPhotoResults: photoResults,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,11 +323,52 @@ func (h *Web) handleAdminSettingsUpdate(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
interval := r.FormValue("scan_interval")
|
interval := r.FormValue("scan_interval")
|
||||||
if _, err := time.ParseDuration(interval); err != nil {
|
if _, err := time.ParseDuration(interval); err != nil {
|
||||||
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger")
|
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = database.SetSetting(h.DB, "scan_interval", interval)
|
_ = database.SetSetting(h.DB, "scan_interval", interval)
|
||||||
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
|
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
|
||||||
_ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_path"))
|
_ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_path"))
|
||||||
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success")
|
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminArtistPhotosRefresh runs a synchronous, admin-triggered
|
||||||
|
// fetch of every eligible artist's photo and reports the outcome for
|
||||||
|
// each one — including failures — rather than the silent best-effort
|
||||||
|
// background fetch normally used when an artist's page is viewed.
|
||||||
|
func (h *Web) handleAdminArtistPhotosRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Unlike the best-effort background fetch on an artist's page (gated
|
||||||
|
// by EnableArtistPhotos, since that one runs unattended on every
|
||||||
|
// visit), this is an explicit, one-off admin action — it runs
|
||||||
|
// regardless of the config toggle so it doubles as a way to check
|
||||||
|
// what's actually happening (bad network, no confident name match,
|
||||||
|
// an API error) without editing the environment first.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
results, err := h.RefreshAllArtistPhotos(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.renderAdminSettings(w, r, "Artist photo refresh failed: "+err.Error(), "danger", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var fetched, notFound, skipped, failed int
|
||||||
|
for _, res := range results {
|
||||||
|
switch {
|
||||||
|
case res.Error != "":
|
||||||
|
failed++
|
||||||
|
case res.Status == "fetched":
|
||||||
|
fetched++
|
||||||
|
case res.Status == "no photo found":
|
||||||
|
notFound++
|
||||||
|
default:
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kind := "success"
|
||||||
|
if failed > 0 {
|
||||||
|
kind = "warning"
|
||||||
|
}
|
||||||
|
flash := fmt.Sprintf("Artist photo refresh complete: %d fetched, %d no photo found, %d skipped, %d failed (see below).",
|
||||||
|
fetched, notFound, skipped, failed)
|
||||||
|
h.renderAdminSettings(w, r, flash, kind, results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID)})
|
views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin)})
|
||||||
}
|
}
|
||||||
h.page(w, r, "bookmarks", "bookmarks", "Bookmarks", views)
|
h.page(w, r, "bookmarks", "bookmarks", "Bookmarks", views)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ func (h *Web) handleCoverArt(w http.ResponseWriter, r *http.Request) {
|
|||||||
// row, extracting embedded artwork to a temp file on demand. isTemp
|
// row, extracting embedded artwork to a temp file on demand. isTemp
|
||||||
// tells the caller whether it must remove the returned path afterward.
|
// tells the caller whether it must remove the returned path afterward.
|
||||||
func (h *Web) resolveCoverArtSource(ctx context.Context, art *database.CoverArt) (path, contentHash string, isTemp bool, err error) {
|
func (h *Web) resolveCoverArtSource(ctx context.Context, art *database.CoverArt) (path, contentHash string, isTemp bool, err error) {
|
||||||
if art.Source == "folder" {
|
if art.Source == "folder" || art.Source == "external" {
|
||||||
|
// "external" is a locally-downloaded artist photo or an
|
||||||
|
// admin-uploaded/URL-sourced cover override — already a plain
|
||||||
|
// file on disk, same as a folder image.
|
||||||
return art.SourcePath, art.ContentHash, false, nil
|
return art.SourcePath, art.ContentHash, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-5
@@ -75,6 +75,7 @@ func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.maybeFetchArtistPhoto(artist)
|
||||||
albums, err := database.ListAlbumsByArtist(h.DB, id)
|
albums, err := database.ListAlbumsByArtist(h.DB, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -85,9 +86,9 @@ func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
|
|||||||
views = append(views, toAlbumView(h.DB, a))
|
views = append(views, toAlbumView(h.DB, a))
|
||||||
}
|
}
|
||||||
h.page(w, r, "artist", "artists", artist.Name, struct {
|
h.page(w, r, "artist", "artists", artist.Name, struct {
|
||||||
Artist *database.Artist
|
Artist ArtistView
|
||||||
Albums []AlbumView
|
Albums []AlbumView
|
||||||
}{artist, views})
|
}{toArtistView(artist), views})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) {
|
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -126,12 +127,14 @@ func (h *Web) handleAlbum(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := ""
|
userID := ""
|
||||||
|
isAdmin := false
|
||||||
if user != nil {
|
if user != nil {
|
||||||
userID = user.ID
|
userID = user.ID
|
||||||
|
isAdmin = user.IsAdmin
|
||||||
}
|
}
|
||||||
var rows []SongRow
|
var rows []SongRow
|
||||||
for _, s := range songs {
|
for _, s := range songs {
|
||||||
rows = append(rows, toSongRow(h.DB, s, album, userID))
|
rows = append(rows, toSongRow(h.DB, s, album, userID, isAdmin))
|
||||||
}
|
}
|
||||||
starred := false
|
starred := false
|
||||||
if user != nil {
|
if user != nil {
|
||||||
@@ -157,12 +160,14 @@ func (h *Web) handleSongs(w http.ResponseWriter, r *http.Request) {
|
|||||||
songs = songs[:pageSize]
|
songs = songs[:pageSize]
|
||||||
}
|
}
|
||||||
userID := ""
|
userID := ""
|
||||||
|
isAdmin := false
|
||||||
if user != nil {
|
if user != nil {
|
||||||
userID = user.ID
|
userID = user.ID
|
||||||
|
isAdmin = user.IsAdmin
|
||||||
}
|
}
|
||||||
var rows []SongRow
|
var rows []SongRow
|
||||||
for _, s := range songs {
|
for _, s := range songs {
|
||||||
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID))
|
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID, isAdmin))
|
||||||
}
|
}
|
||||||
h.page(w, r, "songs", "songs", "Songs", struct {
|
h.page(w, r, "songs", "songs", "Songs", struct {
|
||||||
Songs []SongRow
|
Songs []SongRow
|
||||||
@@ -219,14 +224,16 @@ func (h *Web) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
if songIDRows != nil {
|
if songIDRows != nil {
|
||||||
defer songIDRows.Close()
|
defer songIDRows.Close()
|
||||||
userID := ""
|
userID := ""
|
||||||
|
isAdmin := false
|
||||||
if user != nil {
|
if user != nil {
|
||||||
userID = user.ID
|
userID = user.ID
|
||||||
|
isAdmin = user.IsAdmin
|
||||||
}
|
}
|
||||||
for songIDRows.Next() {
|
for songIDRows.Next() {
|
||||||
var id string
|
var id string
|
||||||
if songIDRows.Scan(&id) == nil {
|
if songIDRows.Scan(&id) == nil {
|
||||||
if song, err := database.GetSong(h.DB, id); err == nil {
|
if song, err := database.GetSong(h.DB, id); err == nil {
|
||||||
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID))
|
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID, isAdmin))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func (h *Web) handlePlaylist(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin))
|
||||||
}
|
}
|
||||||
h.page(w, r, "playlist", "playlists", playlist.Name, struct {
|
h.page(w, r, "playlist", "playlists", playlist.Name, struct {
|
||||||
Playlist *database.Playlist
|
Playlist *database.Playlist
|
||||||
@@ -109,7 +109,7 @@ func (h *Web) handleFavorites(w http.ResponseWriter, r *http.Request) {
|
|||||||
var songs []SongRow
|
var songs []SongRow
|
||||||
for _, id := range songIDs {
|
for _, id := range songIDs {
|
||||||
if s, err := database.GetSong(h.DB, id); err == nil {
|
if s, err := database.GetSong(h.DB, id); err == nil {
|
||||||
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID))
|
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID, user.IsAdmin))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ func (h *Web) handleRecent(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin))
|
||||||
}
|
}
|
||||||
h.page(w, r, "recent", "recent", "Recently Played", rows)
|
h.page(w, r, "recent", "recent", "Recently Played", rows)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,3 +34,25 @@ func (h *Web) handlePodcastDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }()
|
go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }()
|
||||||
http.Redirect(w, r, "/podcasts", http.StatusFound)
|
http.Redirect(w, r, "/podcasts", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Web) handlePodcastDownloadAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if _, err := database.GetPodcastChannel(h.DB, id); err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := h.Podcasts.DownloadChannel(context.Background(), id); err != nil && h.Log != nil {
|
||||||
|
h.Log.Warn("podcast channel download failed", "channel", id, "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
destination := "/podcasts"
|
||||||
|
if next := r.FormValue("next"); next != "" {
|
||||||
|
destination = safeRedirectPath(next)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, destination, http.StatusFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func (h *Web) NewRouter() http.Handler {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /podcasts", h.requireUser(h.handlePodcasts))
|
mux.HandleFunc("GET /podcasts", h.requireUser(h.handlePodcasts))
|
||||||
mux.HandleFunc("POST /podcasts/{id}/download", h.requireUser(h.handlePodcastDownload))
|
mux.HandleFunc("POST /podcasts/{id}/download", h.requireUser(h.handlePodcastDownload))
|
||||||
|
mux.HandleFunc("POST /podcasts/{id}/download-all", h.requireUser(h.handlePodcastDownloadAll))
|
||||||
|
|
||||||
mux.HandleFunc("GET /radio", h.requireUser(h.handleRadio))
|
mux.HandleFunc("GET /radio", h.requireUser(h.handleRadio))
|
||||||
mux.HandleFunc("POST /radio", h.requireAdmin(h.handleRadioCreate))
|
mux.HandleFunc("POST /radio", h.requireAdmin(h.handleRadioCreate))
|
||||||
@@ -92,6 +93,7 @@ func (h *Web) NewRouter() http.Handler {
|
|||||||
mux.HandleFunc("GET /admin/scanner", h.requireAdmin(h.handleAdminScanner))
|
mux.HandleFunc("GET /admin/scanner", h.requireAdmin(h.handleAdminScanner))
|
||||||
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
|
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
|
||||||
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
|
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
|
||||||
|
mux.HandleFunc("POST /admin/settings/artist-photos/refresh", h.requireAdmin(h.handleAdminArtistPhotosRefresh))
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/podcasts", h.requireAdmin(h.handleAdminPodcasts))
|
mux.HandleFunc("GET /admin/podcasts", h.requireAdmin(h.handleAdminPodcasts))
|
||||||
mux.HandleFunc("POST /admin/podcasts", h.requireAdmin(h.handleAdminPodcastCreate))
|
mux.HandleFunc("POST /admin/podcasts", h.requireAdmin(h.handleAdminPodcastCreate))
|
||||||
@@ -100,6 +102,13 @@ func (h *Web) NewRouter() http.Handler {
|
|||||||
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload))
|
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload))
|
||||||
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete))
|
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /admin/artists/{id}/edit", h.requireAdmin(h.handleAdminArtistEdit))
|
||||||
|
mux.HandleFunc("POST /admin/artists/{id}/edit", h.requireAdmin(h.handleAdminArtistUpdate))
|
||||||
|
mux.HandleFunc("GET /admin/albums/{id}/edit", h.requireAdmin(h.handleAdminAlbumEdit))
|
||||||
|
mux.HandleFunc("POST /admin/albums/{id}/edit", h.requireAdmin(h.handleAdminAlbumUpdate))
|
||||||
|
mux.HandleFunc("GET /admin/songs/{id}/edit", h.requireAdmin(h.handleAdminSongEdit))
|
||||||
|
mux.HandleFunc("POST /admin/songs/{id}/edit", h.requireAdmin(h.handleAdminSongUpdate))
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/api-keys", h.requireAdmin(h.handleAdminAPIKeys))
|
mux.HandleFunc("GET /admin/api-keys", h.requireAdmin(h.handleAdminAPIKeys))
|
||||||
mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke))
|
mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke))
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func (h *Web) handleSharePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if serr != nil {
|
if serr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), ""))
|
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), "", false))
|
||||||
}
|
}
|
||||||
|
|
||||||
owner, _ := database.GetUserByID(h.DB, share.OwnerID)
|
owner, _ := database.GetUserByID(h.DB, share.OwnerID)
|
||||||
|
|||||||
+14
-5
@@ -10,8 +10,10 @@ import (
|
|||||||
// scanning details (sql.Null*) out of the .html files.
|
// scanning details (sql.Null*) out of the .html files.
|
||||||
|
|
||||||
type ArtistView struct {
|
type ArtistView struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
|
Bio string
|
||||||
|
CoverArtID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumView struct {
|
type AlbumView struct {
|
||||||
@@ -32,10 +34,15 @@ type SongRow struct {
|
|||||||
TrackNumber int64
|
TrackNumber int64
|
||||||
DurationSeconds float64
|
DurationSeconds float64
|
||||||
Starred bool
|
Starred bool
|
||||||
|
IsAdmin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func toArtistView(a *database.Artist) ArtistView {
|
func toArtistView(a *database.Artist) ArtistView {
|
||||||
return ArtistView{ID: a.ID, Name: a.Name}
|
v := ArtistView{ID: a.ID, Name: a.Name, Bio: a.Bio}
|
||||||
|
if a.CoverArtID.Valid {
|
||||||
|
v.CoverArtID = a.CoverArtID.String
|
||||||
|
}
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
||||||
@@ -56,12 +63,14 @@ func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
|||||||
|
|
||||||
// toSongRow builds a display row for a song, given its owning album (may
|
// toSongRow builds a display row for a song, given its owning album (may
|
||||||
// be nil for albumless tracks) and the current user (for the starred
|
// be nil for albumless tracks) and the current user (for the starred
|
||||||
// flag; pass "" userID to skip that lookup).
|
// flag; pass "" userID to skip that lookup, and isAdmin=false to hide
|
||||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string) SongRow {
|
// the row's "Edit" link).
|
||||||
|
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string, isAdmin bool) SongRow {
|
||||||
row := SongRow{
|
row := SongRow{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
Title: s.Title,
|
Title: s.Title,
|
||||||
DurationSeconds: 0,
|
DurationSeconds: 0,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
}
|
}
|
||||||
if s.TrackNumber.Valid {
|
if s.TrackNumber.Valid {
|
||||||
row.TrackNumber = s.TrackNumber.Int64
|
row.TrackNumber = s.TrackNumber.Int64
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- Artist photos (auto-fetched or manually overridden) reuse the
|
||||||
|
-- existing cover_art table rather than a parallel one, so photo
|
||||||
|
-- resizing/caching/serving (internal/coverart, /cover-art/{id}) works
|
||||||
|
-- unchanged for artists. That requires adding an artist_id column and
|
||||||
|
-- widening the "must belong to something" CHECK, which SQLite can only
|
||||||
|
-- do by rebuilding the table.
|
||||||
|
--
|
||||||
|
-- albums.cover_art_id and podcast_channels.cover_art_id are declared
|
||||||
|
-- "ON DELETE SET NULL" against cover_art(id), and SQLite's DROP TABLE
|
||||||
|
-- performs an implicit DELETE FROM first when foreign_keys=ON — which
|
||||||
|
-- would fire that SET NULL and silently wipe every album's/channel's
|
||||||
|
-- chosen cover on upgrade. Back those columns up and restore them
|
||||||
|
-- after the rebuild.
|
||||||
|
CREATE TEMP TABLE _cover_art_migration_backup_albums AS
|
||||||
|
SELECT id, cover_art_id FROM albums WHERE cover_art_id IS NOT NULL;
|
||||||
|
CREATE TEMP TABLE _cover_art_migration_backup_podcasts AS
|
||||||
|
SELECT id, cover_art_id FROM podcast_channels WHERE cover_art_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE cover_art_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
album_id TEXT REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
media_file_id TEXT REFERENCES media_files(id) ON DELETE CASCADE,
|
||||||
|
artist_id TEXT REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
source TEXT NOT NULL CHECK (source IN ('embedded','folder','external')),
|
||||||
|
source_path TEXT NOT NULL DEFAULT '',
|
||||||
|
mime_type TEXT NOT NULL DEFAULT '',
|
||||||
|
content_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
width INTEGER NOT NULL DEFAULT 0,
|
||||||
|
height INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
CHECK ((album_id IS NOT NULL) OR (media_file_id IS NOT NULL) OR (artist_id IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO cover_art_new (id, album_id, media_file_id, artist_id, source, source_path, mime_type, content_hash, width, height, updated_at)
|
||||||
|
SELECT id, album_id, media_file_id, NULL, source, source_path, mime_type, content_hash, width, height, updated_at FROM cover_art;
|
||||||
|
|
||||||
|
DROP TABLE cover_art;
|
||||||
|
ALTER TABLE cover_art_new RENAME TO cover_art;
|
||||||
|
|
||||||
|
CREATE INDEX idx_cover_art_album ON cover_art(album_id);
|
||||||
|
CREATE INDEX idx_cover_art_media_file ON cover_art(media_file_id);
|
||||||
|
CREATE INDEX idx_cover_art_artist ON cover_art(artist_id);
|
||||||
|
|
||||||
|
UPDATE albums SET cover_art_id = (
|
||||||
|
SELECT cover_art_id FROM _cover_art_migration_backup_albums WHERE _cover_art_migration_backup_albums.id = albums.id
|
||||||
|
) WHERE id IN (SELECT id FROM _cover_art_migration_backup_albums);
|
||||||
|
UPDATE podcast_channels SET cover_art_id = (
|
||||||
|
SELECT cover_art_id FROM _cover_art_migration_backup_podcasts WHERE _cover_art_migration_backup_podcasts.id = podcast_channels.id
|
||||||
|
) WHERE id IN (SELECT id FROM _cover_art_migration_backup_podcasts);
|
||||||
|
|
||||||
|
DROP TABLE _cover_art_migration_backup_albums;
|
||||||
|
DROP TABLE _cover_art_migration_backup_podcasts;
|
||||||
|
|
||||||
|
-- Artist bio + photo (auto-fetched or admin-overridden).
|
||||||
|
ALTER TABLE artists ADD COLUMN bio TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE artists ADD COLUMN cover_art_id TEXT REFERENCES cover_art(id);
|
||||||
|
ALTER TABLE artists ADD COLUMN photo_manual INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Lets an admin edit a song's title/track/disc/composer/conductor
|
||||||
|
-- without a later library rescan silently overwriting the edit.
|
||||||
|
ALTER TABLE songs ADD COLUMN metadata_locked INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -178,6 +178,21 @@ body {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vs-song-art-cell {
|
||||||
|
width: 48px;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-song-art {
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bs-secondary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Player bar --- */
|
/* --- Player bar --- */
|
||||||
|
|
||||||
.vs-player {
|
.vs-player {
|
||||||
@@ -352,3 +367,9 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Inline page transitions keep the persistent player mounted. */
|
||||||
|
.vs-main[aria-busy="true"] {
|
||||||
|
opacity: .65;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
+129
-1
@@ -474,7 +474,134 @@
|
|||||||
form.appendChild(input);
|
form.appendChild(input);
|
||||||
});
|
});
|
||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
form.submit();
|
form.requestSubmit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inline navigation ---
|
||||||
|
// Fetch server-rendered pages and replace only the main content area. The
|
||||||
|
// audio element and player live outside that area, so playback continues
|
||||||
|
// uninterrupted while the URL, title, and active sidebar item still track
|
||||||
|
// the current page normally.
|
||||||
|
function initInlineNavigation() {
|
||||||
|
var pending = null;
|
||||||
|
var currentURL = location.href;
|
||||||
|
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
|
||||||
|
|
||||||
|
function isLocalHTTP(url) {
|
||||||
|
return (url.protocol === "http:" || url.protocol === "https:") && url.origin === location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHandleLink(link, ev) {
|
||||||
|
if (!link || ev.defaultPrevented || ev.button !== 0) return false;
|
||||||
|
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return false;
|
||||||
|
if (link.target || link.hasAttribute("download") || link.dataset.noInline != null) return false;
|
||||||
|
var url = new URL(link.href, location.href);
|
||||||
|
if (!isLocalHTTP(url)) return false;
|
||||||
|
if (url.pathname.indexOf("/static/") === 0 || url.pathname.indexOf("/stream/") === 0 ||
|
||||||
|
url.pathname.indexOf("/cover-art/") === 0 || url.pathname.indexOf("/share/") === 0 ||
|
||||||
|
/\/export$/.test(url.pathname)) return false;
|
||||||
|
// Let the browser handle an in-page fragment without a network request.
|
||||||
|
if (url.pathname === location.pathname && url.search === location.search && url.hash) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(busy) {
|
||||||
|
var main = document.querySelector(".vs-main");
|
||||||
|
if (!main) return;
|
||||||
|
if (busy) main.setAttribute("aria-busy", "true");
|
||||||
|
else main.removeAttribute("aria-busy");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPage(html, responseURL, push, restoreScroll) {
|
||||||
|
var parsed = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
var nextMain = parsed.querySelector(".vs-main");
|
||||||
|
var oldMain = document.querySelector(".vs-main");
|
||||||
|
// Login, setup, shared, and error documents are intentionally full-page.
|
||||||
|
if (!nextMain || !oldMain) return false;
|
||||||
|
|
||||||
|
oldMain.replaceWith(nextMain);
|
||||||
|
document.title = parsed.title || document.title;
|
||||||
|
var tokenScript = parsed.querySelector("script[data-vs-csrf]");
|
||||||
|
if (tokenScript) window.vsCSRFToken = tokenScript.dataset.vsCsrf;
|
||||||
|
|
||||||
|
var activeHref = parsed.querySelector(".vs-sidebar .nav-link.active");
|
||||||
|
document.querySelectorAll(".vs-sidebar .nav-link").forEach(function (link) {
|
||||||
|
link.classList.toggle("active", !!activeHref && link.getAttribute("href") === activeHref.getAttribute("href"));
|
||||||
|
});
|
||||||
|
|
||||||
|
currentURL = responseURL;
|
||||||
|
if (push) history.pushState({ vsInline: true, scrollY: 0 }, "", responseURL);
|
||||||
|
var top = typeof restoreScroll === "number" ? restoreScroll : 0;
|
||||||
|
window.scrollTo(0, top);
|
||||||
|
var autofocus = nextMain.querySelector("[autofocus]");
|
||||||
|
if (autofocus) autofocus.focus({ preventScroll: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(url, options) {
|
||||||
|
options = options || {};
|
||||||
|
if (pending) pending.abort();
|
||||||
|
var controller = new AbortController();
|
||||||
|
pending = controller;
|
||||||
|
setBusy(true);
|
||||||
|
var request = {
|
||||||
|
method: options.method || "GET",
|
||||||
|
body: options.body || null,
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { "X-VibeSonic-Inline": "1" },
|
||||||
|
};
|
||||||
|
return fetch(url, request).then(function (response) {
|
||||||
|
if (!response.ok || !(response.headers.get("Content-Type") || "").includes("text/html")) {
|
||||||
|
throw new Error("not an inline page");
|
||||||
|
}
|
||||||
|
return response.text().then(function (html) {
|
||||||
|
if (!applyPage(html, response.url, options.push !== false, options.restoreScroll)) {
|
||||||
|
location.assign(response.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(function (err) {
|
||||||
|
if (err.name !== "AbortError") {
|
||||||
|
if ((options.method || "GET") === "GET") location.assign(url);
|
||||||
|
else location.reload();
|
||||||
|
}
|
||||||
|
}).finally(function () {
|
||||||
|
if (pending === controller) {
|
||||||
|
pending = null;
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", function (ev) {
|
||||||
|
var link = ev.target.closest("a[href]");
|
||||||
|
if (!shouldHandleLink(link, ev)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
|
||||||
|
navigate(link.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("submit", function (ev) {
|
||||||
|
var form = ev.target;
|
||||||
|
if (ev.defaultPrevented || !(form instanceof HTMLFormElement) || form.dataset.noInline != null) return;
|
||||||
|
var url = new URL(form.action || location.href, location.href);
|
||||||
|
if (!isLocalHTTP(url) || url.pathname === "/logout") return;
|
||||||
|
ev.preventDefault();
|
||||||
|
var data = new FormData(form);
|
||||||
|
var method = (form.method || "GET").toUpperCase();
|
||||||
|
if (method === "GET") {
|
||||||
|
url.search = new URLSearchParams(data).toString();
|
||||||
|
navigate(url.href);
|
||||||
|
} else {
|
||||||
|
navigate(url.href, { method: method, body: data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("popstate", function (ev) {
|
||||||
|
navigate(location.href, {
|
||||||
|
push: false,
|
||||||
|
restoreScroll: ev.state && typeof ev.state.scrollY === "number" ? ev.state.scrollY : 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,5 +610,6 @@
|
|||||||
var player = initPlayer();
|
var player = initPlayer();
|
||||||
initKeyboardShortcuts(player);
|
initKeyboardShortcuts(player);
|
||||||
initStarButtons();
|
initStarButtons();
|
||||||
|
initInlineNavigation();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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}}
|
{{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
{{if .Episodes}}
|
||||||
|
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all" class="d-inline">
|
||||||
|
{{template "csrf_field" $.CSRFToken}}
|
||||||
|
<input type="hidden" name="next" value="/admin/podcasts">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/refresh" class="d-inline">
|
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/refresh" class="d-inline">
|
||||||
{{template "csrf_field" $.CSRFToken}}
|
{{template "csrf_field" $.CSRFToken}}
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
|
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
|
||||||
|
|||||||
@@ -21,6 +21,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3" style="max-width: 600px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6">Artist photos</h2>
|
||||||
|
{{if .Data.ArtistPhotosEnabled}}
|
||||||
|
<p class="text-secondary small mb-2">
|
||||||
|
Fetch a photo (fanart.tv when an artist has a tagged MusicBrainz ID and a key is configured, otherwise a direct name search against Wikidata/Wikimedia Commons) for every artist with no manually-set photo. This never calls MusicBrainz itself, so it isn't affected by its rate limits — matching by name alone is less precise, though. This bypasses any previously-cached "not found" result, so it's a good way to retry after fixing a config or network issue.
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-secondary small mb-2">
|
||||||
|
Automatic background fetching is disabled (<code>VIBESONIC_ENABLE_ARTIST_PHOTOS=false</code>), but this button still runs a one-off fetch — useful for checking why photos aren't showing up.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/admin/settings/artist-photos/refresh">
|
||||||
|
{{template "csrf_field" .CSRFToken}}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">Fetch all artist photos now</button>
|
||||||
|
</form>
|
||||||
|
{{if .Data.ArtistPhotoResults}}
|
||||||
|
<table class="table table-sm vs-table mt-3 mb-0">
|
||||||
|
<thead><tr><th>Artist</th><th>Status</th><th>Error</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.ArtistPhotoResults}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ArtistName}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .Error}}<span class="text-danger">{{.Status}}</span>
|
||||||
|
{{else if eq .Status "fetched"}}<span class="text-success">{{.Status}}</span>
|
||||||
|
{{else}}<span class="text-secondary">{{.Status}}</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="text-danger small">{{.Error}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mt-3" style="max-width: 600px;">
|
<div class="card mt-3" style="max-width: 600px;">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="h6">External authentication</h2>
|
<h2 class="h6">External authentication</h2>
|
||||||
|
|||||||
@@ -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}}
|
{{template "csrf_field" .CSRFToken}}
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
|
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{if and .User .User.IsAdmin}}
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/admin/albums/{{.Data.Album.ID}}/edit"><i class="bi bi-pencil"></i> Edit album</a>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{template "song_rows" .Data.Songs}}
|
{{template "song_rows" .Data.Songs}}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
{{define "artist"}}{{template "header" .}}
|
{{define "artist"}}{{template "header" .}}
|
||||||
|
<div class="d-flex gap-3 mb-4">
|
||||||
|
<img class="vs-card-art" style="width:180px;border-radius:50%;" src="{{if .Data.Artist.CoverArtID}}/cover-art/{{.Data.Artist.CoverArtID}}?size=400{{else}}/static/img/default-art.svg{{end}}" alt="">
|
||||||
|
<div>
|
||||||
|
<h2 class="h4 mb-1">{{.Data.Artist.Name}}</h2>
|
||||||
|
{{if .Data.Artist.Bio}}<p class="text-secondary mb-2">{{.Data.Artist.Bio}}</p>{{end}}
|
||||||
|
{{if and .User .User.IsAdmin}}
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/admin/artists/{{.Data.Artist.ID}}/edit"><i class="bi bi-pencil"></i> Edit artist</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="vs-grid mb-3">
|
<div class="vs-grid mb-3">
|
||||||
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
{{define "artist_card"}}
|
{{define "artist_card"}}
|
||||||
<a href="/artists/{{.ID}}" class="vs-card">
|
<a href="/artists/{{.ID}}" class="vs-card">
|
||||||
<img class="vs-card-art" style="border-radius:50%;" src="/static/img/default-art.svg" alt="" loading="lazy">
|
<img class="vs-card-art" style="border-radius:50%;" src="{{if .CoverArtID}}/cover-art/{{.CoverArtID}}?size=300{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
|
||||||
<div class="vs-card-title">{{.Name}}</div>
|
<div class="vs-card-title">{{.Name}}</div>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -26,11 +26,17 @@
|
|||||||
data-cover="{{$s.CoverArtID}}"
|
data-cover="{{$s.CoverArtID}}"
|
||||||
data-starred="{{if $s.Starred}}1{{else}}0{{end}}">
|
data-starred="{{if $s.Starred}}1{{else}}0{{end}}">
|
||||||
<td class="vs-track-num">{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}}</td>
|
<td class="vs-track-num">{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}}</td>
|
||||||
|
<td class="vs-song-art-cell">
|
||||||
|
<img class="vs-song-art" src="{{if $s.CoverArtID}}/cover-art/{{$s.CoverArtID}}?size=80{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-semibold">{{$s.Title}}</div>
|
<div class="fw-semibold">{{$s.Title}}</div>
|
||||||
{{if $s.AlbumTitle}}<div class="text-secondary small">{{$s.ArtistName}} — {{$s.AlbumTitle}}</div>{{else}}<div class="text-secondary small">{{$s.ArtistName}}</div>{{end}}
|
{{if $s.AlbumTitle}}<div class="text-secondary small">{{$s.ArtistName}} — {{$s.AlbumTitle}}</div>{{else}}<div class="text-secondary small">{{$s.ArtistName}}</div>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end text-secondary small">{{fmtDuration $s.DurationSeconds}}</td>
|
<td class="text-end text-secondary small">
|
||||||
|
{{fmtDuration $s.DurationSeconds}}
|
||||||
|
{{if $s.IsAdmin}}<a href="/admin/songs/{{$s.ID}}/edit" title="Edit song" class="text-secondary ms-2"><i class="bi bi-pencil"></i></a>{{end}}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>window.vsCSRFToken = {{.CSRFToken}};</script>
|
<script data-vs-csrf="{{.CSRFToken}}">window.vsCSRFToken = {{.CSRFToken}};</script>
|
||||||
<header class="vs-topbar d-flex align-items-center justify-content-between">
|
<header class="vs-topbar d-flex align-items-center justify-content-between">
|
||||||
<a href="/" class="vs-brand d-flex align-items-center gap-2">
|
<a href="/" class="vs-brand d-flex align-items-center gap-2">
|
||||||
<i class="bi bi-vinyl-fill"></i>
|
<i class="bi bi-vinyl-fill"></i>
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
{{range .Data}}
|
{{range .Data}}
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="h6">{{.Channel.Title}}</h2>
|
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||||
|
<h2 class="h6 mb-0">{{.Channel.Title}}</h2>
|
||||||
|
{{if .Episodes}}
|
||||||
|
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all">
|
||||||
|
{{template "csrf_field" $.CSRFToken}}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all episodes</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
<p class="text-secondary small">{{.Channel.Description}}</p>
|
<p class="text-secondary small">{{.Channel.Description}}</p>
|
||||||
<table class="table table-sm vs-table mb-0">
|
<table class="table table-sm vs-table mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user