feat(genius): load song metadata, lyrics, and artwork
ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
fb20258489
commit
73ad61e7a5
34 files changed
+2214
-303
No files matched your search
@@ -0,0 +1,176 @@
|
||||
// Package genius retrieves song metadata from the API and plain lyrics from public HTML.
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrLyricsUnavailable = errors.New("genius: lyrics absent or page markup unsupported")
|
||||
|
||||
// HTTPError preserves throttling information without exposing response bodies or credentials.
|
||||
type HTTPError struct {
|
||||
StatusCode int
|
||||
RetryAfter string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string { return fmt.Sprintf("genius: HTTP %d", e.StatusCode) }
|
||||
|
||||
type Artist struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"image_url"`
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
ImageURL string `json:"song_art_image_url"`
|
||||
ThumbURL string `json:"song_art_image_thumbnail_url"`
|
||||
HeaderURL string `json:"header_image_url"`
|
||||
Artist Artist `json:"primary_artist"`
|
||||
Description struct {
|
||||
Plain string `json:"plain"`
|
||||
} `json:"description"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"cover_art_url"`
|
||||
ThumbURL string `json:"cover_art_thumbnail_url"`
|
||||
} `json:"album"`
|
||||
}
|
||||
|
||||
type Lyrics struct {
|
||||
Text string `json:"text"`
|
||||
SourceURL string `json:"source_url"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
token string
|
||||
http *http.Client
|
||||
mu sync.Mutex
|
||||
next time.Time
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// SetInterval must be called before sharing the client with workers.
|
||||
func (c *Client) SetInterval(d time.Duration) { c.interval = max(d, time.Second) }
|
||||
|
||||
func New(token string) *Client {
|
||||
return &Client{token: strings.TrimSpace(token), http: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("genius: too many redirects")
|
||||
}
|
||||
// Do not forward credentials or fetch content outside the initial host.
|
||||
if req.URL.Scheme != "https" || req.URL.Host != via[0].URL.Host || req.URL.User != nil {
|
||||
return errors.New("genius: refused redirect")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// Search returns candidates; callers must select a version rather than assume the first hit matches.
|
||||
func (c *Client) Search(ctx context.Context, query string) ([]Song, error) {
|
||||
if c.token == "" {
|
||||
return nil, errors.New("genius: GENIUS_ACCESS_TOKEN is required for search")
|
||||
}
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, errors.New("genius: empty search query")
|
||||
}
|
||||
body, err := c.get(ctx, "https://api.genius.com/search?q="+url.QueryEscape(query), true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Response struct {
|
||||
Hits []struct {
|
||||
Type string `json:"type"`
|
||||
Result Song `json:"result"`
|
||||
} `json:"hits"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("genius: decode search: %w", err)
|
||||
}
|
||||
songs := make([]Song, 0, len(result.Response.Hits))
|
||||
for _, hit := range result.Response.Hits {
|
||||
if hit.Type == "song" {
|
||||
songs = append(songs, hit.Result)
|
||||
}
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
// FetchLyrics accepts a selected public song URL. The token is never sent to the HTML site.
|
||||
func (c *Client) FetchLyrics(ctx context.Context, rawURL string) (Lyrics, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil || !strings.HasSuffix(u.Path, "-lyrics") {
|
||||
return Lyrics{}, errors.New("genius: expected https://genius.com/<song>-lyrics")
|
||||
}
|
||||
u.RawQuery, u.Fragment = "", ""
|
||||
body, err := c.get(ctx, u.String(), false)
|
||||
if err != nil {
|
||||
return Lyrics{}, err
|
||||
}
|
||||
text, err := parseLyrics(body)
|
||||
if err != nil {
|
||||
return Lyrics{}, err
|
||||
}
|
||||
return Lyrics{Text: text, SourceURL: u.String(), FetchedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, endpoint string, api bool) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
wait := time.Until(c.next)
|
||||
c.next = time.Now().Add(max(wait, 0) + c.interval)
|
||||
c.mu.Unlock()
|
||||
if wait > 0 {
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "PipuMusic/1.0")
|
||||
if api {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
} else {
|
||||
req.Header.Set("Accept", "text/html")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genius: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &HTTPError{StatusCode: resp.StatusCode, RetryAfter: resp.Header.Get("Retry-After")}
|
||||
}
|
||||
const maxBody = 8 << 20
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxBody {
|
||||
return nil, errors.New("genius: response exceeds 8 MiB")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type transportFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func TestLyricsFormatting(t *testing.T) {
|
||||
text, err := parseLyrics([]byte(`<header>Not lyrics</header><div data-lyrics-container="true"><div class="LyricsHeader-x">Title</div>[Куплет 1]<br>Привет & <a href="/annotation">мир</a><br><br>Next <b>line</b><span data-exclude-from-selection="true">Embed</span><script>bad()</script></div><div data-lyrics-container="true">[Припев]<br>Again<button>Share</button></div>`))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "[Куплет 1]\nПривет & мир\n\nNext line\n\n[Припев]\nAgain", text)
|
||||
}
|
||||
|
||||
func TestUnavailable(t *testing.T) {
|
||||
for _, page := range []string{`<h1>Just a moment</h1>`, `<div data-lyrics-container="true"> </div>`, `<script>window.__PRELOADED_STATE__ = JSON.parse('broken')</script>`} {
|
||||
_, err := parseLyrics([]byte(page))
|
||||
require.ErrorIs(t, err, ErrLyricsUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedLyrics(t *testing.T) {
|
||||
text, err := parseLyrics([]byte(`<script>window.__PRELOADED_STATE__ = JSON.parse('{"songPage":{"lyricsData":{"body":{"html":"<p>[Verse]<br>It\'s \\"fine\\" & good<br>Привет</p>"}}}}')</script>`))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "[Verse]\nIt's \"fine\" & good\nПривет", text)
|
||||
}
|
||||
|
||||
func TestURLValidation(t *testing.T) {
|
||||
c := New("secret")
|
||||
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network call"); return nil, nil })
|
||||
for _, u := range []string{"https://evil.test/song-lyrics", "http://genius.com/song-lyrics", "https://genius.com:443/song-lyrics", "https://secret@genius.com/song-lyrics", "https://genius.com/artists/test"} {
|
||||
_, err := c.FetchLyrics(context.Background(), u)
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequests(t *testing.T) {
|
||||
c := New("secret")
|
||||
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body := `<div data-lyrics-container="true">Synthetic test text</div>`
|
||||
if r.URL.Host == "api.genius.com" {
|
||||
require.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "artist & title", r.URL.Query().Get("q"))
|
||||
body = `{"response":{"hits":[{"type":"song","result":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","primary_artist":{"name":"Artist"}}}]}}`
|
||||
} else {
|
||||
require.Empty(t, r.Header.Get("Authorization"))
|
||||
require.Empty(t, r.URL.RawQuery)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})
|
||||
songs, err := c.Search(context.Background(), "artist & title")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, songs, 1)
|
||||
lyrics, err := c.FetchLyrics(context.Background(), songs[0].URL+"?tracking=1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Synthetic test text", lyrics.Text)
|
||||
require.Equal(t, songs[0].URL, lyrics.SourceURL)
|
||||
require.False(t, lyrics.FetchedAt.IsZero())
|
||||
}
|
||||
|
||||
func TestHTTPFailures(t *testing.T) {
|
||||
for _, status := range []int{401, 403, 404, 429, 500} {
|
||||
c := New("secret")
|
||||
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": {"60"}}, Body: io.NopCloser(strings.NewReader("private upstream body"))}, nil
|
||||
})
|
||||
_, err := c.Search(context.Background(), "test")
|
||||
var httpErr *HTTPError
|
||||
require.True(t, errors.As(err, &httpErr))
|
||||
require.Equal(t, status, httpErr.StatusCode)
|
||||
require.Equal(t, "60", httpErr.RetryAfter)
|
||||
require.NotContains(t, err.Error(), "private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirects(t *testing.T) {
|
||||
c := New("secret")
|
||||
first, _ := http.NewRequest("GET", "https://api.genius.com/search", nil)
|
||||
for _, endpoint := range []string{"http://api.genius.com/search", "https://genius.com/song-lyrics", "https://evil.test/"} {
|
||||
req, _ := http.NewRequest("GET", endpoint, nil)
|
||||
require.Error(t, c.http.CheckRedirect(req, []*http.Request{first}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type Handler struct{ svc *Service }
|
||||
|
||||
func NewHandler(s *Service) *Handler { return &Handler{svc: s} }
|
||||
|
||||
func requireAdmin(ctx context.Context) error {
|
||||
p, ok := auth.PrincipalFrom(ctx)
|
||||
if !ok {
|
||||
return apperr.Unauthorized("auth.unauthorized", "authentication required")
|
||||
}
|
||||
if p.Role != auth.RoleAdmin {
|
||||
return apperr.Forbidden("auth.forbidden", "administrator required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetGeniusStats(ctx context.Context, _ gen.GetGeniusStatsRequestObject) (gen.GetGeniusStatsResponseObject, error) {
|
||||
if err := requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats, err := h.svc.Stats(ctx)
|
||||
return gen.GetGeniusStats200JSONResponse(stats), err
|
||||
}
|
||||
|
||||
func (h *Handler) ControlGenius(ctx context.Context, req gen.ControlGeniusRequestObject) (gen.ControlGeniusResponseObject, error) {
|
||||
if err := requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !h.svc.configured {
|
||||
return nil, apperr.Conflict("genius.disabled", "configure GENIUS_ACCESS_TOKEN in deploy/.env")
|
||||
}
|
||||
if req.Body == nil {
|
||||
return nil, apperr.Validation("action", "validation.required", "action required", nil)
|
||||
}
|
||||
tx, err := h.svc.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
switch string(req.Body.Action) {
|
||||
case "pause":
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=true`)
|
||||
case "start":
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`)
|
||||
case "retry":
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_tracks SET status='pending',next_attempt=now(),last_error='' WHERE status IN ('error','partial','unmatched') AND locked_until<=now()`)
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`)
|
||||
}
|
||||
default:
|
||||
return nil, apperr.Validation("action", "validation.invalid", "unknown action", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.svc.Wake()
|
||||
stats, err := h.svc.Stats(ctx)
|
||||
return gen.ControlGenius200JSONResponse(stats), err
|
||||
}
|
||||
|
||||
func (h *Handler) GetTrackGenius(ctx context.Context, req gen.GetTrackGeniusRequestObject) (gen.GetTrackGeniusResponseObject, error) {
|
||||
if _, ok := auth.PrincipalFrom(ctx); !ok {
|
||||
return nil, apperr.Unauthorized("auth.unauthorized", "authentication required")
|
||||
}
|
||||
content := emptyContent()
|
||||
var body []byte
|
||||
err := h.svc.pool.QueryRow(ctx, `SELECT COALESCE(g.content,'{}'),COALESCE(g.status,'pending') FROM tracks t LEFT JOIN genius_tracks g ON g.track_id=t.id WHERE t.id=$1`, req.Id).Scan(&body, &content.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, apperr.NotFound("catalog.not_found", "track not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := content.Status
|
||||
if err = json.Unmarshal(body, &content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content.Status = status
|
||||
if !h.svc.configured && status == "pending" {
|
||||
content.Status = "disabled"
|
||||
}
|
||||
return gen.GetTrackGenius200JSONResponse(content), nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// Parse only designated lyrics containers, never the entire page (navigation, ads, annotations).
|
||||
func parseLyrics(body []byte) (string, error) {
|
||||
doc, err := html.Parse(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var blocks []string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if excluded(n) {
|
||||
return
|
||||
}
|
||||
if attr(n, "data-lyrics-container") == "true" {
|
||||
if text := nodeText(n); text != "" {
|
||||
blocks = append(blocks, text)
|
||||
}
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
if len(blocks) == 0 {
|
||||
if text := embeddedLyrics(doc); text != "" {
|
||||
return text, nil
|
||||
}
|
||||
return "", ErrLyricsUnavailable
|
||||
}
|
||||
return strings.Join(blocks, "\n\n"), nil
|
||||
}
|
||||
|
||||
func attr(n *html.Node, name string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == name {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func excluded(n *html.Node) bool {
|
||||
return n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style" || n.Data == "button" ||
|
||||
attr(n, "data-exclude-from-selection") == "true" || attr(n, "aria-hidden") == "true" ||
|
||||
strings.Contains(attr(n, "class"), "LyricsHeader"))
|
||||
}
|
||||
|
||||
var statePattern = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*JSON\.parse\('((?:[^'\\]|\\.)*)'\)`)
|
||||
|
||||
// Read a JSON string literal only; never evaluate JavaScript from a remote page.
|
||||
func embeddedLyrics(doc *html.Node) string {
|
||||
var result string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if result != "" {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "script" && n.FirstChild != nil {
|
||||
match := statePattern.FindStringSubmatch(n.FirstChild.Data)
|
||||
if len(match) == 2 {
|
||||
decoded, ok := decodeJSString(match[1])
|
||||
if ok {
|
||||
var state struct {
|
||||
SongPage struct {
|
||||
LyricsData struct {
|
||||
Body struct {
|
||||
HTML string `json:"html"`
|
||||
} `json:"body"`
|
||||
} `json:"lyricsData"`
|
||||
} `json:"songPage"`
|
||||
}
|
||||
if json.Unmarshal([]byte(decoded), &state) == nil {
|
||||
fragment, err := html.Parse(strings.NewReader(strings.ReplaceAll(state.SongPage.LyricsData.Body.HTML, "\n", "")))
|
||||
if err == nil {
|
||||
result = nodeText(fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return result
|
||||
}
|
||||
|
||||
func decodeJSString(raw string) (string, bool) {
|
||||
var quoted strings.Builder
|
||||
quoted.WriteByte('"')
|
||||
for i := 0; i < len(raw); i++ {
|
||||
switch raw[i] {
|
||||
case '"':
|
||||
quoted.WriteString(`\"`)
|
||||
case '\\':
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return "", false
|
||||
}
|
||||
switch raw[i] {
|
||||
case '\'':
|
||||
quoted.WriteByte('\'')
|
||||
case 'x':
|
||||
if i+2 >= len(raw) {
|
||||
return "", false
|
||||
}
|
||||
quoted.WriteString(`\u00`)
|
||||
quoted.WriteString(raw[i+1 : i+3])
|
||||
i += 2
|
||||
default:
|
||||
quoted.WriteByte('\\')
|
||||
quoted.WriteByte(raw[i])
|
||||
}
|
||||
default:
|
||||
quoted.WriteByte(raw[i])
|
||||
}
|
||||
}
|
||||
quoted.WriteByte('"')
|
||||
var decoded string
|
||||
err := json.Unmarshal([]byte(quoted.String()), &decoded)
|
||||
return decoded, err == nil
|
||||
}
|
||||
|
||||
func nodeText(root *html.Node) string {
|
||||
var out strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if excluded(n) {
|
||||
return
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
out.WriteString(n.Data)
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "br" {
|
||||
out.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
block := n.Type == html.ElementNode && (n.Data == "p" || n.Data == "div") && n != root
|
||||
if block {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
if block {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
lines := strings.Split(strings.ReplaceAll(out.String(), "\r", ""), "\n")
|
||||
clean := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.Join(strings.Fields(line), " ")
|
||||
if line == "" && (len(clean) == 0 || clean[len(clean)-1] == "") {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(clean, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
)
|
||||
|
||||
func normalize(s string) string {
|
||||
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }), " ")
|
||||
}
|
||||
|
||||
// Exact normalized identity; retain version qualifiers such as live/remix.
|
||||
func matchSong(songs []Song, artist, title string) (Song, bool) {
|
||||
var matched Song
|
||||
for _, s := range songs {
|
||||
if normalize(s.Title) == normalize(title) && normalize(s.Artist.Name) == normalize(artist) {
|
||||
if matched.ID != 0 && matched.ID != s.ID {
|
||||
return Song{}, false
|
||||
}
|
||||
matched = s
|
||||
}
|
||||
}
|
||||
return matched, matched.ID != 0
|
||||
}
|
||||
|
||||
func imageURL(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Scheme != "https" || u.User != nil || strings.Contains(strings.ToLower(u.Path), "default") {
|
||||
return ""
|
||||
}
|
||||
switch u.Host {
|
||||
case "images.genius.com", "images.rapgenius.com", "t2.genius.com":
|
||||
return u.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstImage(urls ...string) string {
|
||||
for _, raw := range urls {
|
||||
if u := imageURL(raw); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// songArtwork prefers the full song resource: search hits often omit album art and the artist image.
|
||||
func songArtwork(song Song) (songImage, artistImage, albumImage string) {
|
||||
return firstImage(song.ImageURL, song.ThumbURL, song.HeaderURL), imageURL(song.Artist.ImageURL), firstImage(song.Album.ImageURL, song.Album.ThumbURL)
|
||||
}
|
||||
|
||||
func urlPath(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
||||
func sourceURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil {
|
||||
return ""
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (c *Client) Song(ctx context.Context, id int64) (Song, error) {
|
||||
if id <= 0 {
|
||||
return Song{}, errors.New("genius: song id required")
|
||||
}
|
||||
body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/songs/%d?text_format=plain", id), true)
|
||||
if err != nil {
|
||||
return Song{}, err
|
||||
}
|
||||
var data struct {
|
||||
Response struct {
|
||||
Song Song `json:"song"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &data); err != nil {
|
||||
return Song{}, fmt.Errorf("genius: decode song: %w", err)
|
||||
}
|
||||
if data.Response.Song.ID == 0 {
|
||||
return Song{}, errors.New("genius: song missing")
|
||||
}
|
||||
return data.Response.Song, nil
|
||||
}
|
||||
|
||||
func (c *Client) Artist(ctx context.Context, id int64) (Artist, error) {
|
||||
if id <= 0 {
|
||||
return Artist{}, errors.New("genius: artist id required")
|
||||
}
|
||||
body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/artists/%d", id), true)
|
||||
if err != nil {
|
||||
return Artist{}, err
|
||||
}
|
||||
var data struct {
|
||||
Response struct {
|
||||
Artist Artist `json:"artist"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &data); err != nil {
|
||||
return Artist{}, fmt.Errorf("genius: decode artist: %w", err)
|
||||
}
|
||||
if data.Response.Artist.ID == 0 {
|
||||
return Artist{}, errors.New("genius: artist missing")
|
||||
}
|
||||
return data.Response.Artist, nil
|
||||
}
|
||||
|
||||
func (c *Client) Annotations(ctx context.Context, id int64) ([]gen.GeniusAnnotation, error) {
|
||||
out := []gen.GeniusAnnotation{}
|
||||
var seen int64
|
||||
haveSeen := false
|
||||
for page := 1; ; page++ {
|
||||
body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/referents?song_id=%d&text_format=plain&per_page=50&page=%d", id, page), true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data struct {
|
||||
Response struct {
|
||||
Referents []struct {
|
||||
ID int64 `json:"id"`
|
||||
Fragment string `json:"fragment"`
|
||||
Annotations []struct {
|
||||
Body struct {
|
||||
Plain string `json:"plain"`
|
||||
} `json:"body"`
|
||||
URL string `json:"url"`
|
||||
Verified bool `json:"verified"`
|
||||
Authors []struct {
|
||||
User struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
} `json:"authors"`
|
||||
} `json:"annotations"`
|
||||
} `json:"referents"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("genius: decode annotations: %w", err)
|
||||
}
|
||||
refs := data.Response.Referents
|
||||
if len(refs) == 0 || (haveSeen && refs[0].ID == seen) {
|
||||
return out, nil
|
||||
}
|
||||
if refs[0].ID != 0 {
|
||||
seen = refs[0].ID
|
||||
haveSeen = true
|
||||
}
|
||||
for _, r := range refs {
|
||||
for _, a := range r.Annotations {
|
||||
if a.Body.Plain == "" {
|
||||
continue
|
||||
}
|
||||
names := []string{}
|
||||
for _, author := range a.Authors {
|
||||
if author.User.Name != "" {
|
||||
names = append(names, author.User.Name)
|
||||
}
|
||||
}
|
||||
out = append(out, gen.GeniusAnnotation{Fragment: r.Fragment, Text: a.Body.Plain, Url: sourceURL(a.URL), Verified: a.Verified, Authors: strings.Join(names, ", ")})
|
||||
}
|
||||
}
|
||||
if len(refs) < 50 {
|
||||
return out, nil
|
||||
}
|
||||
if page == 20 {
|
||||
return nil, fmt.Errorf("genius: annotation pagination limit")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMatchSong(t *testing.T) {
|
||||
songs := []Song{
|
||||
{ID: 1, Title: "Alright (Live)", Artist: Artist{Name: "Kendrick Lamar"}},
|
||||
{ID: 2, Title: "Alright!", Artist: Artist{Name: "Kendrick Lamar"}},
|
||||
{ID: 3, Title: "Alright", Artist: Artist{Name: "Someone Else"}},
|
||||
}
|
||||
got, ok := matchSong(songs, "kendrick-lamar", "Alright")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, int64(2), got.ID)
|
||||
_, ok = matchSong(append(songs, Song{ID: 4, Title: "Alright", Artist: Artist{Name: "Kendrick Lamar"}}), "Kendrick Lamar", "Alright")
|
||||
require.False(t, ok)
|
||||
_, ok = matchSong(songs, "Kendrick Lamar", "Alright Remix")
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestImageURL(t *testing.T) {
|
||||
require.Equal(t, "https://images.genius.com/cover.jpg", imageURL("https://images.genius.com/cover.jpg"))
|
||||
require.Equal(t, "https://images.rapgenius.com/a.png", imageURL("https://images.rapgenius.com/a.png"))
|
||||
for _, raw := range []string{
|
||||
"http://images.genius.com/a.jpg",
|
||||
"https://evil.test/a.jpg",
|
||||
"https://images.genius.com/default_avatar.png",
|
||||
"https://user@images.genius.com/a.jpg",
|
||||
"",
|
||||
} {
|
||||
require.Empty(t, imageURL(raw))
|
||||
}
|
||||
song := Song{
|
||||
ImageURL: "https://images.genius.com/default_cover_image.png",
|
||||
ThumbURL: "https://images.genius.com/song.jpg",
|
||||
Artist: Artist{ImageURL: "https://images.genius.com/artist.jpg"},
|
||||
}
|
||||
song.Album.ImageURL = "https://images.genius.com/album.jpg"
|
||||
songImage, artistImage, albumImage := songArtwork(song)
|
||||
require.Equal(t, "https://images.genius.com/song.jpg", songImage)
|
||||
require.Equal(t, "https://images.genius.com/artist.jpg", artistImage)
|
||||
require.Equal(t, "https://images.genius.com/album.jpg", albumImage)
|
||||
}
|
||||
|
||||
func TestSongAndArtist(t *testing.T) {
|
||||
c := New("secret")
|
||||
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
|
||||
require.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
|
||||
body := `{"response":{"artist":{"id":9,"name":"Artist","image_url":"https://images.genius.com/artist.jpg"}}}`
|
||||
if strings.Contains(r.URL.Path, "/songs/") {
|
||||
require.Equal(t, "plain", r.URL.Query().Get("text_format"))
|
||||
body = `{"response":{"song":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","song_art_image_url":"https://images.genius.com/song.jpg","primary_artist":{"id":9,"name":"Artist"},"album":{"name":"Album","cover_art_url":"https://images.genius.com/album.jpg"},"description":{"plain":"About the song"}}}}`
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})
|
||||
song, err := c.Song(context.Background(), 12)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "About the song", song.Description.Plain)
|
||||
require.Equal(t, "Album", song.Album.Name)
|
||||
artist, err := c.Artist(context.Background(), 9)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://images.genius.com/artist.jpg", artist.ImageURL)
|
||||
_, err = c.Song(context.Background(), 0)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAnnotations(t *testing.T) {
|
||||
c := New("secret")
|
||||
pages := 0
|
||||
one := `{"id":1,"fragment":"line","annotations":[{"body":{"plain":"means this"},"url":"https://genius.com/annotations/1","verified":true,"authors":[{"user":{"name":"Editor"}}]}]}`
|
||||
full := `{"response":{"referents":[` + strings.TrimRight(strings.Repeat(one+",", 50), ",") + `]}}`
|
||||
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
|
||||
pages++
|
||||
require.Equal(t, "plain", r.URL.Query().Get("text_format"))
|
||||
body := full
|
||||
if pages > 1 {
|
||||
body = `{"response":{"referents":[` + one + `]}}`
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})
|
||||
notes, err := c.Annotations(context.Background(), 12)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, pages)
|
||||
require.Len(t, notes, 50)
|
||||
require.Equal(t, "line", notes[0].Fragment)
|
||||
require.Equal(t, "means this", notes[0].Text)
|
||||
require.Equal(t, "Editor", notes[0].Authors)
|
||||
require.True(t, notes[0].Verified)
|
||||
require.Equal(t, "https://genius.com/annotations/1", notes[0].Url)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"pipu/api/internal/api/gen"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
client *Client
|
||||
configured bool
|
||||
log *slog.Logger
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, token string, interval time.Duration, log *slog.Logger) *Service {
|
||||
client := New(token)
|
||||
client.SetInterval(interval)
|
||||
return &Service{pool: pool, client: client, configured: strings.TrimSpace(token) != "", log: log, wake: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (s *Service) Wake() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run discovers new tracks, including imports, without making catalog requests wait for Genius.
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
if !s.configured {
|
||||
return
|
||||
}
|
||||
timer := time.NewTicker(30 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
if err := s.drain(ctx); err != nil && ctx.Err() == nil {
|
||||
s.log.Error("genius worker", "err", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
case <-s.wake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) drain(ctx context.Context) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO genius_tracks(track_id) SELECT id FROM tracks ON CONFLICT DO NOTHING`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
var id uuid.UUID
|
||||
// Atomic claim and persisted lease recover interrupted work after a restart.
|
||||
err := s.pool.QueryRow(ctx, `UPDATE genius_tracks SET status='processing', locked_until=now()+interval '10 minutes', attempts=attempts+1
|
||||
WHERE track_id=(SELECT track_id FROM genius_tracks WHERE status IN ('pending','processing','error','partial')
|
||||
AND next_attempt<=now() AND locked_until<=now()
|
||||
AND EXISTS(SELECT 1 FROM genius_state WHERE NOT paused AND cooldown_until<=now())
|
||||
ORDER BY next_attempt FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING track_id`).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
content, albumImage, err := s.enrich(jobCtx, id)
|
||||
cancel()
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
status, message, next := "ready", "", time.Now().Add(30*24*time.Hour)
|
||||
if content.SourceUrl == "" {
|
||||
status = "unmatched"
|
||||
}
|
||||
if err != nil {
|
||||
message = errorCode(err)
|
||||
status = "error"
|
||||
if content.SourceUrl != "" {
|
||||
status = "partial"
|
||||
}
|
||||
next = time.Now().Add(6 * time.Hour)
|
||||
}
|
||||
content.Status = status
|
||||
body, marshalErr := json.Marshal(content)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
tx, txErr := s.pool.Begin(ctx)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
saveErr := func() error {
|
||||
if _, e := tx.Exec(ctx, `UPDATE genius_tracks SET status=$2,content=$3,last_error=$4,next_attempt=$5,
|
||||
locked_until='1970-01-01',updated_at=now() WHERE track_id=$1`, id, status, body, message, next); e != nil {
|
||||
return e
|
||||
}
|
||||
if content.ArtistImageUrl != "" {
|
||||
if _, e := tx.Exec(ctx, `UPDATE artists SET genius_image_url=$2 WHERE id=(SELECT artist_id FROM track_artists
|
||||
WHERE track_id=$1 AND role='main' ORDER BY position LIMIT 1) AND image_path IS NULL AND genius_image_url IS NULL`, id, content.ArtistImageUrl); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if albumImage != "" {
|
||||
if _, e := tx.Exec(ctx, `UPDATE albums SET genius_cover_url=$2 WHERE id=(SELECT album_id FROM tracks WHERE id=$1)
|
||||
AND cover_path IS NULL AND genius_cover_url IS NULL`, id, albumImage); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
pause := false
|
||||
cooldown := time.Now()
|
||||
var he *HTTPError
|
||||
if errors.As(err, &he) {
|
||||
pause = he.StatusCode == 401 || he.StatusCode == 403
|
||||
if he.StatusCode == 429 {
|
||||
cooldown = time.Now().Add(retryDelay(he.RetryAfter))
|
||||
}
|
||||
}
|
||||
if _, e := tx.Exec(ctx, `UPDATE genius_state SET last_error=$1, paused=paused OR $2,
|
||||
cooldown_until=GREATEST(cooldown_until,$3)`, message, pause, cooldown); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}()
|
||||
_ = tx.Rollback(ctx)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func emptyContent() gen.GeniusContent {
|
||||
return gen.GeniusContent{Status: "pending", Annotations: []gen.GeniusAnnotation{}}
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if r := []rune(s); len(r) > n {
|
||||
return strings.TrimSpace(string(r[:n]))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) enrich(ctx context.Context, id uuid.UUID) (gen.GeniusContent, string, error) {
|
||||
content := emptyContent()
|
||||
var title, artist, album string
|
||||
var old []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT t.title, COALESCE((SELECT a.name FROM track_artists ta JOIN artists a ON a.id=ta.artist_id
|
||||
WHERE ta.track_id=t.id AND ta.role='main' ORDER BY ta.position LIMIT 1),''), al.title, g.content
|
||||
FROM tracks t JOIN albums al ON al.id=t.album_id JOIN genius_tracks g ON g.track_id=t.id WHERE t.id=$1`, id).Scan(&title, &artist, &album, &old)
|
||||
if err != nil {
|
||||
return content, "", err
|
||||
}
|
||||
if err = json.Unmarshal(old, &content); err != nil {
|
||||
return content, "", err
|
||||
}
|
||||
if artist == "" || title == "" {
|
||||
return content, "", nil
|
||||
}
|
||||
songs, err := s.client.Search(ctx, artist+" "+title)
|
||||
if err != nil {
|
||||
return content, "", err
|
||||
}
|
||||
song, ok := matchSong(songs, artist, title)
|
||||
if !ok {
|
||||
return content, "", nil
|
||||
}
|
||||
content.SourceUrl = sourceURL(song.URL)
|
||||
content.SongImageUrl, content.ArtistImageUrl, _ = songArtwork(song)
|
||||
details, err := s.client.Song(ctx, song.ID)
|
||||
if err != nil {
|
||||
return content, "", err
|
||||
}
|
||||
if u := sourceURL(details.URL); u != "" {
|
||||
content.SourceUrl = u
|
||||
}
|
||||
songImage, artistImage, albumImage := songArtwork(details)
|
||||
if songImage != "" {
|
||||
content.SongImageUrl = songImage
|
||||
}
|
||||
if artistImage != "" {
|
||||
content.ArtistImageUrl = artistImage
|
||||
}
|
||||
if normalize(details.Album.Name) != normalize(album) {
|
||||
albumImage = ""
|
||||
}
|
||||
if text := clip(details.Description.Plain, 8000); text != "" {
|
||||
content.Description = text
|
||||
}
|
||||
if content.ArtistImageUrl == "" && details.Artist.ID > 0 {
|
||||
geniusArtist, artistErr := s.client.Artist(ctx, details.Artist.ID)
|
||||
if artistErr != nil {
|
||||
var he *HTTPError
|
||||
if !errors.As(artistErr, &he) || he.StatusCode != http.StatusNotFound {
|
||||
return content, albumImage, artistErr
|
||||
}
|
||||
} else {
|
||||
content.ArtistImageUrl = imageURL(geniusArtist.ImageURL)
|
||||
}
|
||||
}
|
||||
pageURL := song.URL
|
||||
if candidate := sourceURL(details.URL); strings.HasSuffix(urlPath(candidate), "-lyrics") {
|
||||
pageURL = candidate
|
||||
}
|
||||
annotations, err := s.client.Annotations(ctx, song.ID)
|
||||
if err != nil {
|
||||
return content, albumImage, err
|
||||
}
|
||||
content.Annotations = annotations
|
||||
lyrics, err := s.client.FetchLyrics(ctx, pageURL)
|
||||
if err != nil {
|
||||
return content, albumImage, err
|
||||
}
|
||||
content.Lyrics = lyrics.Text
|
||||
return content, albumImage, nil
|
||||
}
|
||||
|
||||
func errorCode(err error) string {
|
||||
var he *HTTPError
|
||||
if errors.As(err, &he) {
|
||||
return "http_" + strconv.Itoa(he.StatusCode)
|
||||
}
|
||||
if errors.Is(err, ErrLyricsUnavailable) {
|
||||
return "lyrics_unavailable"
|
||||
}
|
||||
return "request_failed"
|
||||
}
|
||||
|
||||
func retryDelay(raw string) time.Duration {
|
||||
if seconds, err := strconv.Atoi(raw); err == nil {
|
||||
return max(time.Minute, min(time.Duration(seconds)*time.Second, 24*time.Hour))
|
||||
}
|
||||
if date, err := http.ParseTime(raw); err == nil {
|
||||
return max(time.Minute, min(time.Until(date), 24*time.Hour))
|
||||
}
|
||||
return 15 * time.Minute
|
||||
}
|
||||
|
||||
func (s *Service) Stats(ctx context.Context) (gen.GeniusStats, error) {
|
||||
out := gen.GeniusStats{Configured: s.configured}
|
||||
err := s.pool.QueryRow(ctx, `SELECT paused,last_error,cooldown_until FROM genius_state`).Scan(&out.Paused, &out.LastError, &out.CooldownUntil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
err = s.pool.QueryRow(ctx, `SELECT count(*),
|
||||
count(*) FILTER(WHERE g.track_id IS NULL OR g.status IN ('pending','processing')),
|
||||
count(*) FILTER(WHERE COALESCE(g.content->>'source_url','')<>''),
|
||||
count(*) FILTER(WHERE COALESCE(g.content->>'lyrics','')<>''),
|
||||
count(*) FILTER(WHERE jsonb_array_length(COALESCE(g.content->'annotations','[]'))>0),
|
||||
count(*) FILTER(WHERE COALESCE(g.content->>'song_image_url','')<>'' OR COALESCE(g.content->>'artist_image_url','')<>''),
|
||||
count(*) FILTER(WHERE g.status='unmatched'),
|
||||
count(*) FILTER(WHERE g.status IN ('error','partial')),
|
||||
COALESCE(bool_or(g.status='processing' AND g.locked_until>now()),false)
|
||||
FROM tracks t LEFT JOIN genius_tracks g ON g.track_id=t.id`).Scan(&out.Total, &out.Pending, &out.Matched, &out.Lyrics, &out.Annotations, &out.Images, &out.Unmatched, &out.Errors, &out.Running)
|
||||
return out, err
|
||||
}
|
||||
Reference in new issue
Block a user