Files
pipu-music/services/api/internal/genius/service.go
T
mrleo1nidandCursor 73ad61e7a5
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
feat(genius): load song metadata, lyrics, and artwork
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>
2026-10-22 08:23:54 +03:00

273 lines
8.4 KiB
Go

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
}