Add alternative names and cover art for soundtracks
CI / docker (push) Failing after 22s
CI / integration (push) Successful in 26s
CI / test (push) Successful in 26s
CI / publish (push) Skipped

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:
SansanandClaude Opus 5.5 committed 2026-10-13 21:57:18 +02:00
1 parent f79f51ea4c
commit bb8de5548d
39 files changed
+2418 -99

No files matched your search

+87
View File
@@ -193,6 +193,93 @@ func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository
return repository.Soundtrack{}, pgx.ErrNoRows
}
func (s *Store) soundtrackByUUID(id uuid.UUID) int {
return slices.IndexFunc(s.Soundtracks, func(st repository.Soundtrack) bool { return st.Uuid == id })
}
func (s *Store) updateSoundtrackByUUID(id uuid.UUID, f func(*repository.Soundtrack)) error {
defer s.lock()()
if s.Err != nil {
return s.Err
}
if i := s.soundtrackByUUID(id); i >= 0 {
f(&s.Soundtracks[i])
}
return nil
}
func (s *Store) SetAlternativeNames(_ context.Context, arg repository.SetAlternativeNamesParams) (repository.Soundtrack, error) {
defer s.lock()()
if s.Err != nil {
return repository.Soundtrack{}, s.Err
}
i := s.soundtrackByUUID(arg.Uuid)
if i < 0 || s.Soundtracks[i].Deleted != nil {
return repository.Soundtrack{}, pgx.ErrNoRows
}
st := &s.Soundtracks[i]
st.AlternativeNames = slices.Clone(arg.Names)
if st.CoverFile == nil {
st.CoverChecked = pgtype.Timestamptz{}
}
return *st, nil
}
func (s *Store) ListSoundtracksNeedingCover(
_ context.Context, checkedBefore pgtype.Timestamptz,
) ([]repository.ListSoundtracksNeedingCoverRow, error) {
defer s.lock()()
if s.Err != nil {
return nil, s.Err
}
var rows []repository.ListSoundtracksNeedingCoverRow
for _, st := range sortedByName(slices.Clone(s.Soundtracks)) {
if st.Deleted != nil || st.CoverFile != nil || st.CoverManual ||
(st.CoverChecked.Valid && !st.CoverChecked.Time.Before(checkedBefore.Time)) {
continue
}
rows = append(rows, repository.ListSoundtracksNeedingCoverRow{
Uuid: st.Uuid, SoundtrackName: st.SoundtrackName, AlternativeNames: st.AlternativeNames,
})
}
return rows, nil
}
func (s *Store) SetCover(_ context.Context, arg repository.SetCoverParams) error {
return s.updateSoundtrackByUUID(arg.Uuid, func(st *repository.Soundtrack) {
now := pgtype.Timestamptz{Time: time.Now(), Valid: true}
st.CoverFile, st.CoverSource, st.CoverExternalID = arg.File, arg.Source, arg.ExternalID
st.CoverManual, st.CoverModified, st.CoverChecked = arg.Manual, now, now
})
}
func (s *Store) MarkCoverChecked(_ context.Context, id uuid.UUID) error {
return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) {
st.CoverChecked = pgtype.Timestamptz{Time: time.Now(), Valid: true}
})
}
func (s *Store) ClearCover(_ context.Context, id uuid.UUID) error {
return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) {
st.CoverFile, st.CoverSource, st.CoverExternalID, st.CoverManual = nil, nil, nil, false
st.CoverModified, st.CoverChecked = pgtype.Timestamptz{}, pgtype.Timestamptz{}
})
}
func (s *Store) ListCoverFiles(context.Context) ([]string, error) {
defer s.lock()()
if s.Err != nil {
return nil, s.Err
}
var files []string
for _, st := range s.Soundtracks {
if st.CoverFile != nil {
files = append(files, *st.CoverFile)
}
}
return files, nil
}
func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error {
defer s.lock()()
if s.Err != nil {