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>
411 lines
12 KiB
Go
411 lines
12 KiB
Go
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
|
|
}
|