Add alternative names and cover art for soundtracks
Alternative names (#32): - PUT /api/v1/soundtracks/{uuid}/alternative-names replaces a soundtrack's alternative names (trimmed, deduplicated, without the soundtrack's own name). - alternative_names is in the soundtrack and statistics responses. - /find matches them too and shows which name matched. Covers (#12), stored in COVERS_PATH and off when it is not set: - GET, PUT (upload) and DELETE /api/v1/soundtracks/{uuid}/cover, and PUT .../cover/source to pick a SteamGridDB game or AniList anime by ID. Uploaded and picked covers are never replaced automatically. - With STEAMGRIDDB_API_KEY, a background job searches SteamGridDB, then AniList, by name, alternative names and the name without bracketed parts, at startup, after a sync and after names change. Search results are ranked by matching words, so "Civilization IV" gets "Sid Meier's Civilization IV". - cover_modified (Unix seconds) is in the soundtrack and statistics responses, so clients can cache covers. The new columns are added to migration 000006, which is not released yet. Closes #12, closes #32 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f79f51ea4c
commit
bb8de5548d
39 files changed
+2418
-99
No files matched your search
@@ -0,0 +1,410 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxCoverBytes is the largest cover image that is stored.
|
||||
MaxCoverBytes = 10 << 20
|
||||
// coverRecheckAfter is how long a soundtrack without a match waits before the job
|
||||
// searches for it again. Changing its alternative names makes it search right away.
|
||||
coverRecheckAfter = 30 * 24 * time.Hour
|
||||
// coverRequestInterval spaces out requests to the external APIs, to stay well inside
|
||||
// their rate limits.
|
||||
coverRequestInterval = time.Second
|
||||
)
|
||||
|
||||
// SourceUpload is the source of a cover that was uploaded.
|
||||
const SourceUpload = "upload"
|
||||
|
||||
// coverTypes are the image types stored as covers, by MIME type, with their file extension.
|
||||
var coverTypes = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
// CoverService stores cover images for soundtracks in a folder and finds them in external
|
||||
// databases in the background.
|
||||
type CoverService struct {
|
||||
q repository.Querier
|
||||
dir string
|
||||
sources []coverSource
|
||||
client *http.Client
|
||||
// interval spaces out external requests; tests set it to 0.
|
||||
interval time.Duration
|
||||
|
||||
trigger chan struct{}
|
||||
// writeMu keeps the job from replacing a cover that is being set by hand.
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
// CoverOptions configures where covers are searched.
|
||||
type CoverOptions struct {
|
||||
// SteamGridDBKey turns on SteamGridDB and the background job.
|
||||
SteamGridDBKey string
|
||||
// SteamGridDBURL and AniListURL replace the real APIs, for tests.
|
||||
SteamGridDBURL string
|
||||
AniListURL string
|
||||
}
|
||||
|
||||
// NewCoverService stores covers in dir. With a SteamGridDB API key the background job
|
||||
// searches SteamGridDB, then AniList; without one there is no background job, but covers
|
||||
// can still be uploaded or picked by ID.
|
||||
func NewCoverService(q repository.Querier, dir string, opts CoverOptions) (*CoverService, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
c := &CoverService{
|
||||
q: q,
|
||||
dir: dir,
|
||||
client: client,
|
||||
interval: coverRequestInterval,
|
||||
trigger: make(chan struct{}, 1),
|
||||
}
|
||||
if opts.SteamGridDBKey != "" {
|
||||
c.sources = append(c.sources, &steamGridDB{
|
||||
baseURL: cmp.Or(opts.SteamGridDBURL, steamGridDBURL), key: opts.SteamGridDBKey, client: client,
|
||||
})
|
||||
}
|
||||
c.sources = append(c.sources, &aniList{url: cmp.Or(opts.AniListURL, aniListURL), client: client})
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SearchesAutomatically reports whether the background job runs.
|
||||
func (c *CoverService) SearchesAutomatically() bool {
|
||||
return c.source("steamgriddb") != nil
|
||||
}
|
||||
|
||||
func (c *CoverService) source(name string) coverSource {
|
||||
for _, s := range c.sources {
|
||||
if s.Name() == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returns the cover image of an active soundtrack, or ErrNotFound.
|
||||
func (c *CoverService) Path(ctx context.Context, id uuid.UUID) (string, error) {
|
||||
st, err := c.q.GetSoundtrackByUUID(ctx, id)
|
||||
if err != nil {
|
||||
return "", notFound(err)
|
||||
}
|
||||
if st.CoverFile == nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return filepath.Join(c.dir, *st.CoverFile), nil
|
||||
}
|
||||
|
||||
// Upload stores data as the soundtrack's cover. It is never replaced by the background job.
|
||||
func (c *CoverService) Upload(ctx context.Context, id uuid.UUID, data []byte) error {
|
||||
if _, err := c.activeSoundtrack(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
return c.store(ctx, id, data, SourceUpload, nil, true)
|
||||
}
|
||||
|
||||
// FromSource stores the cover of entry externalID in source (steamgriddb or anilist) as
|
||||
// the soundtrack's cover. It is never replaced by the background job.
|
||||
func (c *CoverService) FromSource(ctx context.Context, id uuid.UUID, source, externalID string) error {
|
||||
s := c.source(source)
|
||||
if s == nil {
|
||||
return fmt.Errorf("%w: unknown or disabled cover source %q", ErrInvalidName, source)
|
||||
}
|
||||
if _, err := c.activeSoundtrack(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
match, err := s.ByID(ctx, externalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.download(ctx, match.ImageURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
return c.store(ctx, id, data, source, &match.ExternalID, true)
|
||||
}
|
||||
|
||||
// Remove deletes the soundtrack's cover, so the background job searches for a new one.
|
||||
func (c *CoverService) Remove(ctx context.Context, id uuid.UUID) error {
|
||||
st, err := c.activeSoundtrack(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if err := c.q.ClearCover(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if st.CoverFile != nil {
|
||||
c.removeFile(*st.CoverFile)
|
||||
}
|
||||
c.Trigger()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CoverService) activeSoundtrack(ctx context.Context, id uuid.UUID) (repository.Soundtrack, error) {
|
||||
st, err := c.q.GetSoundtrackByUUID(ctx, id)
|
||||
return st, notFound(err)
|
||||
}
|
||||
|
||||
// store writes data as the soundtrack's cover file and records it. Call with writeMu held.
|
||||
func (c *CoverService) store(ctx context.Context, id uuid.UUID, data []byte, source string, externalID *string, manual bool) error {
|
||||
ext, err := coverExtension(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := id.String() + ext
|
||||
tmp, err := os.CreateTemp(c.dir, ".upload-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), filepath.Join(c.dir, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
// A cover of another image type leaves a file with another extension behind.
|
||||
for _, other := range coverTypes {
|
||||
if other != ext {
|
||||
c.removeFile(id.String() + other)
|
||||
}
|
||||
}
|
||||
return c.q.SetCover(ctx, repository.SetCoverParams{
|
||||
Uuid: id, File: &name, Source: &source, ExternalID: externalID, Manual: manual,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CoverService) removeFile(name string) {
|
||||
if err := os.Remove(filepath.Join(c.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
logging.GetLogger().Warn("Failed to remove cover", zap.String("file", name), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// coverExtension checks that data is a supported image and returns its file extension.
|
||||
func coverExtension(data []byte) (string, error) {
|
||||
if len(data) > MaxCoverBytes {
|
||||
return "", fmt.Errorf("%w: the image is larger than %d MB", ErrInvalidImage, MaxCoverBytes>>20)
|
||||
}
|
||||
ext, ok := coverTypes[http.DetectContentType(data)]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: only JPEG, PNG and WebP images are supported", ErrInvalidImage)
|
||||
}
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// download fetches a cover image.
|
||||
func (c *CoverService) download(ctx context.Context, imageURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid image URL: %v", ErrUpstream, err)
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: image download answered %s", ErrUpstream, resp.Status)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, MaxCoverBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
|
||||
}
|
||||
if _, err := coverExtension(data); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Trigger asks the background job to look for missing covers. It never blocks.
|
||||
func (c *CoverService) Trigger() {
|
||||
select {
|
||||
case c.trigger <- struct{}{}:
|
||||
default: // a pass is already waiting
|
||||
}
|
||||
}
|
||||
|
||||
// Run searches for missing covers now and each time Trigger is called, until ctx ends.
|
||||
// It does nothing when automatic search is off.
|
||||
func (c *CoverService) Run(ctx context.Context) {
|
||||
if !c.SearchesAutomatically() {
|
||||
return
|
||||
}
|
||||
c.Trigger()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.trigger:
|
||||
if err := c.searchMissing(ctx); err != nil && ctx.Err() == nil {
|
||||
logging.GetLogger().Warn("Cover search stopped, trying again after the next sync", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// searchMissing finds covers for active soundtracks without one. It stops at the first
|
||||
// error from an external API, so an unreachable API is not asked again for every soundtrack.
|
||||
func (c *CoverService) searchMissing(ctx context.Context) error {
|
||||
c.removeOrphans(ctx)
|
||||
rows, err := c.q.ListSoundtracksNeedingCover(ctx,
|
||||
pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter), Valid: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger := logging.GetLogger()
|
||||
found := 0
|
||||
for _, row := range rows {
|
||||
match, source, err := c.search(ctx, coverSearchNames(row.SoundtrackName, row.AlternativeNames))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if match == nil {
|
||||
if err := c.q.MarkCoverChecked(ctx, row.Uuid); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := c.wait(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.download(ctx, match.ImageURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored, err := c.storeFound(ctx, row.Uuid, data, source, match.ExternalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stored {
|
||||
found++
|
||||
logger.Info("Cover found", zap.String("soundtrack", row.SoundtrackName),
|
||||
zap.String("source", source), zap.String("external_id", match.ExternalID))
|
||||
}
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
logger.Info("Cover search finished", zap.Int("searched", len(rows)), zap.Int("found", found))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeFound stores a cover found by the job, unless one was set by hand meanwhile.
|
||||
func (c *CoverService) storeFound(ctx context.Context, id uuid.UUID, data []byte, source, externalID string) (bool, error) {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
st, err := c.q.GetSoundtrackByUUID(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil // removed meanwhile
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if st.CoverManual || st.CoverFile != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, c.store(ctx, id, data, source, &externalID, false)
|
||||
}
|
||||
|
||||
// search asks the sources in order and returns the first match.
|
||||
func (c *CoverService) search(ctx context.Context, names []string) (*coverMatch, string, error) {
|
||||
for _, s := range c.sources {
|
||||
if err := c.wait(ctx); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
match, err := s.Search(ctx, names)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%s: %w", s.Name(), err)
|
||||
}
|
||||
if match != nil {
|
||||
return match, s.Name(), nil
|
||||
}
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
func (c *CoverService) wait(ctx context.Context) error {
|
||||
if c.interval <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(c.interval):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// removeOrphans deletes cover files that no soundtrack uses any more, for example after
|
||||
// the library was reset.
|
||||
func (c *CoverService) removeOrphans(ctx context.Context) {
|
||||
files, err := c.q.ListCoverFiles(ctx)
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("Failed to list cover files", zap.Error(err))
|
||||
return
|
||||
}
|
||||
used := make(map[string]bool, len(files))
|
||||
for _, f := range files {
|
||||
used[f] = true
|
||||
}
|
||||
entries, err := os.ReadDir(c.dir)
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("Failed to read the covers folder", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && !used[entry.Name()] {
|
||||
c.removeFile(entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bracketed = regexp.MustCompile(`\s*[(\[][^)\]]*[)\]]`)
|
||||
|
||||
// coverSearchNames returns the names to search for: the soundtrack's name, its alternative
|
||||
// names, and the name without parts in brackets ("Lemmings (SNES + Mega Drive)" →
|
||||
// "Lemmings"), without duplicates.
|
||||
func coverSearchNames(name string, alternatives []string) []string {
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
for _, n := range append(append([]string{name}, alternatives...), bracketed.ReplaceAllString(name, "")) {
|
||||
n = strings.TrimSpace(n)
|
||||
if n != "" && !seen[strings.ToLower(n)] {
|
||||
seen[strings.ToLower(n)] = true
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
Reference in new issue
Block a user