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
+68
-10
@@ -3,23 +3,30 @@ package backend
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// Soundtrack is a soundtrack as returned by the API.
|
||||
type Soundtrack struct {
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
LastPlayed *time.Time `json:"last_played,omitempty"`
|
||||
Added time.Time `json:"added"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
LastPlayed *time.Time `json:"last_played,omitempty"`
|
||||
Added time.Time `json:"added"`
|
||||
CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."`
|
||||
}
|
||||
|
||||
// MaxNameLength is the longest alternative name that can be stored, in bytes.
|
||||
const MaxNameLength = 200
|
||||
|
||||
// SoundtrackWithSongList is a soundtrack with all its songs.
|
||||
type SoundtrackWithSongList struct {
|
||||
Soundtrack
|
||||
@@ -48,14 +55,65 @@ func NewLibraryService(q repository.Querier) *LibraryService {
|
||||
|
||||
func toSoundtrack(s repository.Soundtrack) Soundtrack {
|
||||
return Soundtrack{
|
||||
Uuid: s.Uuid,
|
||||
Name: s.SoundtrackName,
|
||||
TimesPlayed: s.TimesPlayed,
|
||||
LastPlayed: s.LastPlayed,
|
||||
Added: s.Added,
|
||||
Uuid: s.Uuid,
|
||||
Name: s.SoundtrackName,
|
||||
AlternativeNames: nonNil(s.AlternativeNames),
|
||||
TimesPlayed: s.TimesPlayed,
|
||||
LastPlayed: s.LastPlayed,
|
||||
Added: s.Added,
|
||||
CoverModified: unixSeconds(s.CoverModified),
|
||||
}
|
||||
}
|
||||
|
||||
// nonNil returns names, or an empty list for nil, so the JSON is [] and never null.
|
||||
func nonNil(names []string) []string {
|
||||
if names == nil {
|
||||
return []string{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// unixSeconds returns t in Unix seconds, or nil when it is not set.
|
||||
func unixSeconds(t pgtype.Timestamptz) *int64 {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
seconds := t.Time.Unix()
|
||||
return &seconds
|
||||
}
|
||||
|
||||
// SetAlternativeNames replaces the alternative names of an active soundtrack. The names
|
||||
// are trimmed, and empty names, duplicates and names equal to the soundtrack's own name
|
||||
// (ignoring case) are dropped.
|
||||
func (l *LibraryService) SetAlternativeNames(ctx context.Context, id uuid.UUID, names []string) (Soundtrack, error) {
|
||||
row, err := l.q.GetSoundtrackByUUID(ctx, id)
|
||||
if err != nil {
|
||||
return Soundtrack{}, notFound(err)
|
||||
}
|
||||
row, err = l.q.SetAlternativeNames(ctx, repository.SetAlternativeNamesParams{
|
||||
Uuid: id, Names: cleanAlternativeNames(row.SoundtrackName, names),
|
||||
})
|
||||
if err != nil {
|
||||
return Soundtrack{}, notFound(err)
|
||||
}
|
||||
return toSoundtrack(row), nil
|
||||
}
|
||||
|
||||
func cleanAlternativeNames(name string, names []string) []string {
|
||||
cleaned := []string{}
|
||||
seen := map[string]bool{strings.ToLower(strings.TrimSpace(name)): true}
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(n)
|
||||
key := strings.ToLower(n)
|
||||
if n == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
cleaned = append(cleaned, n)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func notFound(err error) error {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
|
||||
Reference in new issue
Block a user