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
@@ -14,3 +14,6 @@ main
|
|||||||
# Generated with `just openapi`
|
# Generated with `just openapi`
|
||||||
/openapi.json
|
/openapi.json
|
||||||
/openapi-3.0.json
|
/openapi-3.0.json
|
||||||
|
|
||||||
|
# Local cover images (COVERS_PATH)
|
||||||
|
/covers/
|
||||||
@@ -36,6 +36,8 @@ VOLUME /sorted
|
|||||||
VOLUME /characters
|
VOLUME /characters
|
||||||
|
|
||||||
# Required: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME, MUSIC_PATH, CHARACTERS_PATH
|
# Required: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME, MUSIC_PATH, CHARACTERS_PATH
|
||||||
|
# Covers (optional): COVERS_PATH, a writable folder such as a /covers volume, turns covers on.
|
||||||
|
# STEAMGRIDDB_API_KEY also turns on the automatic search (SteamGridDB, then AniList).
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
|
|
||||||
CMD ["./main"]
|
CMD ["./main"]
|
||||||
@@ -135,6 +135,12 @@ main {
|
|||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.matched-name {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#search_term {
|
#search_term {
|
||||||
|
|||||||
+27
-9
@@ -33,15 +33,33 @@ func NewSearchHandler(cache *backend.SearchCache) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchResult is a soundtrack found by search. MatchedName is the alternative name that
|
||||||
|
// matched, or empty when the soundtrack's own name matched.
|
||||||
|
type SearchResult struct {
|
||||||
|
Name string
|
||||||
|
MatchedName string
|
||||||
|
}
|
||||||
|
|
||||||
// search returns the soundtracks matching searchText: exact matches first, then matches
|
// search returns the soundtracks matching searchText: exact matches first, then matches
|
||||||
// ignoring spaces and special characters, then matches with the letters in order.
|
// ignoring spaces and special characters, then matches with the letters in order. Each
|
||||||
func search(searchText string, soundtrackData []backend.SoundtrackData) []string {
|
// step tries the soundtrack's own name before its alternative names.
|
||||||
var results []string
|
func search(searchText string, soundtrackData []backend.SoundtrackData) []SearchResult {
|
||||||
|
var results []SearchResult
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
add := func(match func(backend.SoundtrackData) bool) {
|
add := func(match func(backend.SearchName) bool) {
|
||||||
for _, data := range soundtrackData {
|
for _, data := range soundtrackData {
|
||||||
if !seen[data.Original] && match(data) {
|
if seen[data.Original] {
|
||||||
results = append(results, data.Original)
|
continue
|
||||||
|
}
|
||||||
|
matched, ok := "", match(data.SearchName)
|
||||||
|
for _, alternative := range data.Alternatives {
|
||||||
|
if ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
matched, ok = alternative.Original, match(alternative)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
results = append(results, SearchResult{Name: data.Original, MatchedName: matched})
|
||||||
seen[data.Original] = true
|
seen[data.Original] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,8 +68,8 @@ func search(searchText string, soundtrackData []backend.SoundtrackData) []string
|
|||||||
lowercase := strings.ToLower(searchText)
|
lowercase := strings.ToLower(searchText)
|
||||||
cleaned := backend.CleanSearchTerm(searchText)
|
cleaned := backend.CleanSearchTerm(searchText)
|
||||||
|
|
||||||
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Lowercase, lowercase) })
|
add(func(n backend.SearchName) bool { return strings.Contains(n.Lowercase, lowercase) })
|
||||||
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Cleaned, cleaned) })
|
add(func(n backend.SearchName) bool { return strings.Contains(n.Cleaned, cleaned) })
|
||||||
add(func(d backend.SoundtrackData) bool { return backend.MatchesSubsequence(cleaned, d.Cleaned) })
|
add(func(n backend.SearchName) bool { return backend.MatchesSubsequence(cleaned, n.Cleaned) })
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
@@ -40,10 +40,13 @@ templ SearchForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ FoundSoundtracks(soundtracks []string) {
|
templ FoundSoundtracks(soundtracks []SearchResult) {
|
||||||
for _, soundtrack := range soundtracks {
|
for _, soundtrack := range soundtracks {
|
||||||
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
||||||
<p class="soundtrack-text">{ soundtrack }</p>
|
<p class="soundtrack-text">{ soundtrack.Name }</p>
|
||||||
|
if soundtrack.MatchedName != "" {
|
||||||
|
<p class="matched-name">Also known as { soundtrack.MatchedName }</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+49
-6
@@ -28,8 +28,24 @@ var soundtracks = []string{
|
|||||||
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
|
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCache(names []string) *backend.SearchCache {
|
func newCache(names []string, alternatives ...map[string][]string) *backend.SearchCache {
|
||||||
return backend.NewSearchCache(func(context.Context) ([]string, error) { return names, nil })
|
soundtracks := make([]backend.SearchSoundtrack, len(names))
|
||||||
|
for i, name := range names {
|
||||||
|
soundtracks[i] = backend.SearchSoundtrack{Name: name}
|
||||||
|
for _, a := range alternatives {
|
||||||
|
soundtracks[i].AlternativeNames = a[name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return backend.NewSearchCache(func(context.Context) ([]backend.SearchSoundtrack, error) { return soundtracks, nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
// names returns the soundtrack names of results.
|
||||||
|
func names(results []SearchResult) []string {
|
||||||
|
var n []string
|
||||||
|
for _, r := range results {
|
||||||
|
n = append(n, r.Name)
|
||||||
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchData(t *testing.T) []backend.SoundtrackData {
|
func searchData(t *testing.T) []backend.SoundtrackData {
|
||||||
@@ -62,23 +78,50 @@ func TestSearch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.query, func(t *testing.T) {
|
t.Run(tt.query, func(t *testing.T) {
|
||||||
assert.Equal(t, tt.want, search(tt.query, data))
|
assert.Equal(t, tt.want, names(search(tt.query, data)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchEmptyReturnsEverything(t *testing.T) {
|
func TestSearchEmptyReturnsEverything(t *testing.T) {
|
||||||
assert.Equal(t, soundtracks, search("", searchData(t)))
|
assert.Equal(t, soundtracks, names(search("", searchData(t))))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchOrdering(t *testing.T) {
|
func TestSearchOrdering(t *testing.T) {
|
||||||
// "MG" is an exact match, "Metal Gear Solid..." only matches by letters in order.
|
// "MG" is an exact match, "Metal Gear Solid..." only matches by letters in order.
|
||||||
results := search("mg", searchData(t))
|
results := names(search("mg", searchData(t)))
|
||||||
require.GreaterOrEqual(t, len(results), 2)
|
require.GreaterOrEqual(t, len(results), 2)
|
||||||
assert.Equal(t, "MG", results[0])
|
assert.Equal(t, "MG", results[0])
|
||||||
assert.Contains(t, results, "Metal Gear Solid 4 - Guns of the Patriots")
|
assert.Contains(t, results, "Metal Gear Solid 4 - Guns of the Patriots")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchAlternativeNames(t *testing.T) {
|
||||||
|
data, err := newCache(soundtracks, map[string][]string{
|
||||||
|
"Final Fantasy VII": {"FF7", "ファイナルファンタジーVII"},
|
||||||
|
"Kingdom Hearts": {"KH"},
|
||||||
|
}).Get(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII", MatchedName: "FF7"}}, search("ff7", data))
|
||||||
|
assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII", MatchedName: "ファイナルファンタジーVII"}}, search("ファイナル", data))
|
||||||
|
assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII"}}, search("fantasy", data),
|
||||||
|
"no matched name when the soundtrack's own name matches")
|
||||||
|
// An exact match on an alternative name comes before a letters-in-order match on a name.
|
||||||
|
results := search("kh", data)
|
||||||
|
require.NotEmpty(t, results)
|
||||||
|
assert.Equal(t, SearchResult{Name: "Kingdom Hearts", MatchedName: "KH"}, results[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoundSoundtracksShowsMatchedName(t *testing.T) {
|
||||||
|
var out strings.Builder
|
||||||
|
require.NoError(t, FoundSoundtracks([]SearchResult{
|
||||||
|
{Name: "Final Fantasy VII", MatchedName: "FF7"}, {Name: "Kingdom Hearts"},
|
||||||
|
}).Render(context.Background(), &out))
|
||||||
|
assert.Contains(t, out.String(), "Final Fantasy VII")
|
||||||
|
assert.Contains(t, out.String(), "Also known as FF7")
|
||||||
|
assert.Equal(t, 1, strings.Count(out.String(), "Also known as"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearchHandler(t *testing.T) {
|
func TestSearchHandler(t *testing.T) {
|
||||||
handler := NewSearchHandler(newCache(soundtracks))
|
handler := NewSearchHandler(newCache(soundtracks))
|
||||||
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader(url.Values{"search_term": {"kingdom"}}.Encode()))
|
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader(url.Values{"search_term": {"kingdom"}}.Encode()))
|
||||||
@@ -93,7 +136,7 @@ func TestSearchHandler(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchHandlerLoadError(t *testing.T) {
|
func TestSearchHandlerLoadError(t *testing.T) {
|
||||||
cache := backend.NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") })
|
cache := backend.NewSearchCache(func(context.Context) ([]backend.SearchSoundtrack, error) { return nil, errors.New("db down") })
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil))
|
NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil))
|
||||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// coverMatch is a cover found by a source.
|
||||||
|
type coverMatch struct {
|
||||||
|
ExternalID string
|
||||||
|
ImageURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverSource finds cover images in an external database.
|
||||||
|
type coverSource interface {
|
||||||
|
// Name is stored as the cover's source, e.g. "steamgriddb".
|
||||||
|
Name() string
|
||||||
|
// Search returns the cover for the first of names that the source knows, or nil when
|
||||||
|
// none matched. An error means the source could not be asked.
|
||||||
|
Search(ctx context.Context, names []string) (*coverMatch, error)
|
||||||
|
// ByID returns the cover of the entry with the given ID, or ErrNotFound.
|
||||||
|
ByID(ctx context.Context, id string) (*coverMatch, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJSON sends req and decodes a JSON response into v. A 404 gives ErrNotFound, other
|
||||||
|
// failures ErrUpstream.
|
||||||
|
func getJSON(client *http.Client, req *http.Request, v any) error {
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrUpstream, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("%w: %s answered %s", ErrUpstream, req.URL.Host, resp.Status)
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(v); err != nil {
|
||||||
|
return fmt.Errorf("%w: invalid response from %s: %v", ErrUpstream, req.URL.Host, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// steamGridDB finds game box art ("grids") on SteamGridDB. It needs an API key.
|
||||||
|
type steamGridDB struct {
|
||||||
|
baseURL string
|
||||||
|
key string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
const steamGridDBURL = "https://www.steamgriddb.com/api/v2"
|
||||||
|
|
||||||
|
func (s *steamGridDB) Name() string { return "steamgriddb" }
|
||||||
|
|
||||||
|
func (s *steamGridDB) get(ctx context.Context, path string, query url.Values, v any) error {
|
||||||
|
u := s.baseURL + path
|
||||||
|
if len(query) > 0 {
|
||||||
|
u += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.key)
|
||||||
|
var wrapper struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := getJSON(s.client, req, &wrapper); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !wrapper.Success {
|
||||||
|
return fmt.Errorf("%w: SteamGridDB request failed", ErrUpstream)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(wrapper.Data, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type steamGridDBGame struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *steamGridDB) Search(ctx context.Context, names []string) (*coverMatch, error) {
|
||||||
|
for _, name := range names {
|
||||||
|
var games []steamGridDBGame
|
||||||
|
if err := s.get(ctx, "/search/autocomplete/"+url.PathEscape(name), nil, &games); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(games) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match, err := s.ByID(ctx, strconv.Itoa(bestGame(name, games).ID))
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
continue // the game has no box art
|
||||||
|
}
|
||||||
|
return match, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *steamGridDB) ByID(ctx context.Context, id string) (*coverMatch, error) {
|
||||||
|
if _, err := strconv.Atoi(id); err != nil {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
query := url.Values{
|
||||||
|
"dimensions": {"600x900"}, // portrait box art
|
||||||
|
"types": {"static"},
|
||||||
|
"mimes": {"image/png,image/jpeg"},
|
||||||
|
"nsfw": {"false"},
|
||||||
|
"humor": {"false"},
|
||||||
|
}
|
||||||
|
var grids []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := s.get(ctx, "/grids/game/"+id, query, &grids); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(grids) == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return &coverMatch{ExternalID: id, ImageURL: grids[0].URL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bestGame picks the search result for name:
|
||||||
|
// 1. a result with exactly the words of name, in any order ("Lemmings 3D" → "3D Lemmings");
|
||||||
|
// 2. else a result that ends with the words of name, as words added in front are usually a
|
||||||
|
// publisher or series ("Civilization IV" → "Sid Meier's Civilization IV");
|
||||||
|
// 3. else SteamGridDB's own first result. Words added after the name can be a subtitle, a
|
||||||
|
// sequel or an expansion ("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), which only
|
||||||
|
// SteamGridDB's ranking can tell apart.
|
||||||
|
func bestGame(name string, games []steamGridDBGame) steamGridDBGame {
|
||||||
|
want := titleWords(name)
|
||||||
|
for _, game := range games {
|
||||||
|
if sameWords(titleWords(game.Name), want) {
|
||||||
|
return game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, game := range games {
|
||||||
|
if have := titleWords(game.Name); len(have) > len(want) && slices.Equal(have[len(have)-len(want):], want) {
|
||||||
|
return game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return games[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameWords reports whether a and b have the same words, in any order.
|
||||||
|
func sameWords(a, b []string) bool {
|
||||||
|
return len(a) == len(b) && slices.Equal(slices.Sorted(slices.Values(a)), slices.Sorted(slices.Values(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// romanNumerals are read as numbers, so "Octopath Traveler 2" matches "Octopath Traveler II".
|
||||||
|
var romanNumerals = map[string]string{
|
||||||
|
"i": "1", "ii": "2", "iii": "3", "iv": "4", "v": "5", "vi": "6", "vii": "7", "viii": "8", "ix": "9", "x": "10",
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleWords returns the words of a title in lower case, without punctuation.
|
||||||
|
func titleWords(title string) []string {
|
||||||
|
words := strings.FieldsFunc(strings.ToLower(title), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
})
|
||||||
|
for i, word := range words {
|
||||||
|
if number, ok := romanNumerals[word]; ok {
|
||||||
|
words[i] = number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
|
// aniList finds anime covers on AniList. It needs no key. Its search is loose, so a
|
||||||
|
// result is only used when one of its titles is the searched name.
|
||||||
|
type aniList struct {
|
||||||
|
url string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
const aniListURL = "https://graphql.anilist.co"
|
||||||
|
|
||||||
|
const aniListFields = `id title { romaji english native } coverImage { extraLarge large }`
|
||||||
|
|
||||||
|
type aniListMedia struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title struct {
|
||||||
|
Romaji string `json:"romaji"`
|
||||||
|
English string `json:"english"`
|
||||||
|
Native string `json:"native"`
|
||||||
|
} `json:"title"`
|
||||||
|
CoverImage struct {
|
||||||
|
ExtraLarge string `json:"extraLarge"`
|
||||||
|
Large string `json:"large"`
|
||||||
|
} `json:"coverImage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m aniListMedia) match() *coverMatch {
|
||||||
|
image := m.CoverImage.ExtraLarge
|
||||||
|
if image == "" {
|
||||||
|
image = m.CoverImage.Large
|
||||||
|
}
|
||||||
|
if image == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &coverMatch{ExternalID: strconv.Itoa(m.ID), ImageURL: image}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *aniList) Name() string { return "anilist" }
|
||||||
|
|
||||||
|
func (a *aniList) query(ctx context.Context, query string, variables map[string]any) (*aniListMedia, error) {
|
||||||
|
body, err := json.Marshal(map[string]any{"query": query, "variables": variables})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
var resp struct {
|
||||||
|
Data struct {
|
||||||
|
Media *aniListMedia `json:"Media"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := getJSON(a.client, req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Data.Media == nil {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return resp.Data.Media, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *aniList) Search(ctx context.Context, names []string) (*coverMatch, error) {
|
||||||
|
for _, name := range names {
|
||||||
|
media, err := a.query(ctx, `query ($search: String) { Media(search: $search, type: ANIME) { `+aniListFields+` } }`,
|
||||||
|
map[string]any{"search": name})
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
want := normalizeTitle(name)
|
||||||
|
for _, title := range []string{media.Title.Romaji, media.Title.English, media.Title.Native} {
|
||||||
|
if title != "" && normalizeTitle(title) == want {
|
||||||
|
if match := media.match(); match != nil {
|
||||||
|
return match, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *aniList) ByID(ctx context.Context, id string) (*coverMatch, error) {
|
||||||
|
n, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
media, err := a.query(ctx, `query ($id: Int) { Media(id: $id) { `+aniListFields+` } }`, map[string]any{"id": n})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if match := media.match(); match != nil {
|
||||||
|
return match, nil
|
||||||
|
}
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeTitle keeps only the letters and digits of a title, in lower case, so
|
||||||
|
// "Cowboy Bebop: The Movie" and "cowboy bebop the movie" compare equal.
|
||||||
|
func normalizeTitle(title string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(title) {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
"music-server/internal/testutil/pgtest"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAlternativeNamesAndCoversWithPostgres checks the alternative name and cover queries
|
||||||
|
// against a real database.
|
||||||
|
func TestAlternativeNamesAndCoversWithPostgres(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
|
||||||
|
s := NewSyncService(q, copyTestMusic(t), nil)
|
||||||
|
require.True(t, s.Start(false))
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
library := NewLibraryService(q)
|
||||||
|
soundtracks, err := library.Soundtracks(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, len(soundtracks), 2)
|
||||||
|
first, second := soundtracks[0], soundtracks[1]
|
||||||
|
assert.Equal(t, []string{}, first.AlternativeNames)
|
||||||
|
assert.Nil(t, first.CoverModified)
|
||||||
|
|
||||||
|
// Alternative names, in every soundtrack response.
|
||||||
|
updated, err := library.SetAlternativeNames(ctx, first.Uuid, []string{" Alt ", "alt", first.Name, "Другое"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"Alt", "Другое"}, updated.AlternativeNames)
|
||||||
|
stats, err := NewStatisticsService(q).AllSoundtracks(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"Alt", "Другое"}, stats[0].AlternativeNames)
|
||||||
|
never, err := NewStatisticsService(q).NeverPlayedSoundtracks(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"Alt", "Другое"}, never[0].AlternativeNames)
|
||||||
|
search, err := NewMusicService(q, t.TempDir()).SearchSoundtracks(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"Alt", "Другое"}, search[0].AlternativeNames)
|
||||||
|
|
||||||
|
// Covers.
|
||||||
|
c, err := NewCoverService(q, filepath.Join(t.TempDir(), "covers"), CoverOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.Upload(ctx, first.Uuid, pngBytes(t)))
|
||||||
|
withCover, err := library.Soundtrack(ctx, first.Uuid)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, withCover.CoverModified)
|
||||||
|
stats, err = NewStatisticsService(q).AllSoundtracks(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, withCover.CoverModified, stats[0].CoverModified)
|
||||||
|
|
||||||
|
needing, err := q.ListSoundtracksNeedingCover(ctx, nowPlus(0))
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, row := range needing {
|
||||||
|
assert.NotEqual(t, first.Uuid, row.Uuid, "a soundtrack with a cover is not searched")
|
||||||
|
}
|
||||||
|
require.NoError(t, q.MarkCoverChecked(ctx, second.Uuid))
|
||||||
|
needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60))
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, row := range needing {
|
||||||
|
assert.NotEqual(t, second.Uuid, row.Uuid, "searched recently")
|
||||||
|
}
|
||||||
|
|
||||||
|
// New names make a soundtrack without a cover searched again.
|
||||||
|
_, err = library.SetAlternativeNames(ctx, second.Uuid, []string{"Other"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var found bool
|
||||||
|
for _, row := range needing {
|
||||||
|
if row.Uuid == second.Uuid {
|
||||||
|
found = true
|
||||||
|
assert.Equal(t, []string{"Other"}, row.AlternativeNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found)
|
||||||
|
|
||||||
|
files, err := q.ListCoverFiles(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{first.Uuid.String() + ".png"}, files)
|
||||||
|
require.NoError(t, c.Remove(ctx, first.Uuid))
|
||||||
|
files, err = q.ListCoverFiles(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nowPlus(seconds int) pgtype.Timestamptz {
|
||||||
|
return pgtype.Timestamptz{Time: time.Now().Add(time.Duration(seconds) * time.Second), Valid: true}
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
"music-server/internal/testutil/fakedb"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pngBytes(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3))))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverAPIs fakes SteamGridDB, AniList and the image host.
|
||||||
|
type coverAPIs struct {
|
||||||
|
t *testing.T
|
||||||
|
server *httptest.Server
|
||||||
|
// games maps search terms to SteamGridDB game IDs; 404 has no grids.
|
||||||
|
games map[string]int
|
||||||
|
// anime maps search terms to AniList titles; the ID is always 42.
|
||||||
|
anime map[string]string
|
||||||
|
// failSteamGridDB makes SteamGridDB answer 500.
|
||||||
|
failSteamGridDB bool
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
searches []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCoverAPIs(t *testing.T) *coverAPIs {
|
||||||
|
a := &coverAPIs{t: t, games: map[string]int{}, anime: map[string]string{}}
|
||||||
|
image := pngBytes(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
reply := func(w http.ResponseWriter, data any) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data})
|
||||||
|
}
|
||||||
|
mux.HandleFunc("GET /sgdb/search/autocomplete/{term}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.record("sgdb:" + r.PathValue("term"))
|
||||||
|
if a.failSteamGridDB {
|
||||||
|
http.Error(w, "down", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, "Bearer key", r.Header.Get("Authorization"))
|
||||||
|
if id, ok := a.games[r.PathValue("term")]; ok {
|
||||||
|
reply(w, []map[string]any{{"id": id, "name": r.PathValue("term")}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(w, []any{})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /sgdb/grids/game/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "600x900", r.URL.Query().Get("dimensions"))
|
||||||
|
if r.PathValue("id") == "404" {
|
||||||
|
reply(w, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(w, []map[string]any{{"id": 1, "url": a.server.URL + "/img/grid-" + r.PathValue("id") + ".png"}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /anilist", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Variables struct {
|
||||||
|
Search string `json:"search"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
} `json:"variables"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||||
|
a.record("anilist:" + req.Variables.Search)
|
||||||
|
title, ok := a.anime[req.Variables.Search]
|
||||||
|
if req.Variables.ID == 42 {
|
||||||
|
title, ok = "By ID", true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": nil}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": map[string]any{
|
||||||
|
"id": 42, "title": map[string]any{"romaji": title},
|
||||||
|
"coverImage": map[string]any{"extraLarge": a.server.URL + "/img/anime.png"},
|
||||||
|
}}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /img/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.PathValue("name") == "text.png" {
|
||||||
|
_, _ = w.Write([]byte("not an image"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(image)
|
||||||
|
})
|
||||||
|
a.server = httptest.NewServer(mux)
|
||||||
|
t.Cleanup(a.server.Close)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *coverAPIs) record(search string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.searches = append(a.searches, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *coverAPIs) searched() []string {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return append([]string(nil), a.searches...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *coverAPIs) options(withKey bool) CoverOptions {
|
||||||
|
opts := CoverOptions{SteamGridDBURL: a.server.URL + "/sgdb", AniListURL: a.server.URL + "/anilist"}
|
||||||
|
if withKey {
|
||||||
|
opts.SteamGridDBKey = "key"
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCovers(t *testing.T, store *fakedb.Store, opts CoverOptions) *CoverService {
|
||||||
|
t.Helper()
|
||||||
|
c, err := NewCoverService(store, filepath.Join(t.TempDir(), "covers"), opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
c.interval = 0
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func coverOf(t *testing.T, store *fakedb.Store, name string) repository.Soundtrack {
|
||||||
|
t.Helper()
|
||||||
|
st, ok := store.Soundtrack(name)
|
||||||
|
require.True(t, ok)
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverSearchFindsCovers(t *testing.T) {
|
||||||
|
apis := newCoverAPIs(t)
|
||||||
|
apis.games["Chrono Trigger"] = 1
|
||||||
|
apis.games["FF7"] = 7
|
||||||
|
apis.games["Lemmings"] = 3
|
||||||
|
apis.anime["Cowboy Bebop"] = "Cowboy Bebop"
|
||||||
|
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
|
||||||
|
store := fakedb.New()
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
|
||||||
|
ff := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII", AlternativeNames: []string{"FF7"}})
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Lemmings (SNES + Mega Drive)"})
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cowboy Bebop"})
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Unknown"})
|
||||||
|
c := newCovers(t, store, apis.options(true))
|
||||||
|
|
||||||
|
require.NoError(t, c.searchMissing(context.Background()))
|
||||||
|
|
||||||
|
chrono := coverOf(t, store, "Chrono Trigger")
|
||||||
|
require.NotNil(t, chrono.CoverFile)
|
||||||
|
assert.Equal(t, chrono.Uuid.String()+".png", *chrono.CoverFile)
|
||||||
|
assert.Equal(t, "steamgriddb", *chrono.CoverSource)
|
||||||
|
assert.Equal(t, "1", *chrono.CoverExternalID)
|
||||||
|
assert.False(t, chrono.CoverManual)
|
||||||
|
assert.True(t, chrono.CoverModified.Valid)
|
||||||
|
assert.FileExists(t, filepath.Join(c.dir, *chrono.CoverFile))
|
||||||
|
|
||||||
|
assert.Equal(t, "7", *coverOf(t, store, "Final Fantasy VII").CoverExternalID, "found by its alternative name")
|
||||||
|
assert.Equal(t, "3", *coverOf(t, store, "Lemmings (SNES + Mega Drive)").CoverExternalID, "found without the part in brackets")
|
||||||
|
bebop := coverOf(t, store, "Cowboy Bebop")
|
||||||
|
assert.Equal(t, "anilist", *bebop.CoverSource, "AniList when SteamGridDB has nothing")
|
||||||
|
|
||||||
|
unknown := coverOf(t, store, "Unknown")
|
||||||
|
assert.Nil(t, unknown.CoverFile)
|
||||||
|
assert.True(t, unknown.CoverChecked.Valid, "marked as searched")
|
||||||
|
assert.NotContains(t, apis.searched(), "anilist:FF7", "SteamGridDB found it first")
|
||||||
|
_ = ff
|
||||||
|
|
||||||
|
// Searched soundtracks are not searched again until the recheck time.
|
||||||
|
before := len(apis.searched())
|
||||||
|
require.NoError(t, c.searchMissing(context.Background()))
|
||||||
|
assert.Len(t, apis.searched(), before)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverSearchAniListNeedsAnExactTitle(t *testing.T) {
|
||||||
|
apis := newCoverAPIs(t)
|
||||||
|
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
|
||||||
|
store := fakedb.New()
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"})
|
||||||
|
c := newCovers(t, store, apis.options(true))
|
||||||
|
|
||||||
|
require.NoError(t, c.searchMissing(context.Background()))
|
||||||
|
|
||||||
|
assert.Nil(t, coverOf(t, store, "Final Fantasy VII").CoverFile, "a near match is not used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverSearchStopsWhenAnAPIFails(t *testing.T) {
|
||||||
|
apis := newCoverAPIs(t)
|
||||||
|
apis.failSteamGridDB = true
|
||||||
|
store := fakedb.New()
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"})
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B"})
|
||||||
|
c := newCovers(t, store, apis.options(true))
|
||||||
|
|
||||||
|
err := c.searchMissing(context.Background())
|
||||||
|
assert.ErrorIs(t, err, ErrUpstream)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"sgdb:A"}, apis.searched(), "the other soundtracks are not tried")
|
||||||
|
assert.False(t, coverOf(t, store, "A").CoverChecked.Valid, "searched again next time")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverSearchSkipsManualCovers(t *testing.T) {
|
||||||
|
apis := newCoverAPIs(t)
|
||||||
|
apis.games["Chrono Trigger"] = 1
|
||||||
|
store := fakedb.New()
|
||||||
|
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
|
||||||
|
c := newCovers(t, store, apis.options(true))
|
||||||
|
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
|
||||||
|
require.NoError(t, store.ClearCover(context.Background(), uuid.Nil)) // no-op
|
||||||
|
|
||||||
|
require.NoError(t, c.searchMissing(context.Background()))
|
||||||
|
|
||||||
|
assert.Empty(t, apis.searched())
|
||||||
|
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
|
||||||
|
|
||||||
|
// A cover set by hand while the job was searching is kept.
|
||||||
|
stored, err := c.storeFound(context.Background(), st.Uuid, pngBytes(t), "steamgriddb", "1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, stored)
|
||||||
|
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverSearchAgainAfterRecheckTime(t *testing.T) {
|
||||||
|
apis := newCoverAPIs(t)
|
||||||
|
store := fakedb.New()
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Old",
|
||||||
|
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter - time.Hour), Valid: true}})
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Recent",
|
||||||
|
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}})
|
||||||
|
c := newCovers(t, store, apis.options(true))
|
||||||
|
|
||||||
|
require.NoError(t, c.searchMissing(context.Background()))
|
||||||
|
|
||||||
|
assert.Contains(t, apis.searched(), "sgdb:Old")
|
||||||
|
assert.NotContains(t, apis.searched(), "sgdb:Recent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverUpload(t *testing.T) {
|
||||||
|
store := fakedb.New()
|
||||||
|
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||||
|
c := newCovers(t, store, CoverOptions{})
|
||||||
|
ctx := context.Background()
|
||||||
|
jpeg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, make([]byte, 100)...)
|
||||||
|
|
||||||
|
require.NoError(t, c.Upload(ctx, st.Uuid, jpeg))
|
||||||
|
got := coverOf(t, store, "Game")
|
||||||
|
assert.Equal(t, st.Uuid.String()+".jpg", *got.CoverFile)
|
||||||
|
assert.True(t, got.CoverManual)
|
||||||
|
assert.Nil(t, got.CoverExternalID)
|
||||||
|
path, err := c.Path(ctx, st.Uuid)
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, _ := os.ReadFile(path)
|
||||||
|
assert.Equal(t, jpeg, data)
|
||||||
|
|
||||||
|
// A PNG replaces the JPEG, and its old file is removed.
|
||||||
|
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
|
||||||
|
assert.Equal(t, st.Uuid.String()+".png", *coverOf(t, store, "Game").CoverFile)
|
||||||
|
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".jpg"))
|
||||||
|
|
||||||
|
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, []byte("<svg></svg>")), ErrInvalidImage)
|
||||||
|
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, append(pngBytes(t), make([]byte, MaxCoverBytes)...)), ErrInvalidImage)
|
||||||
|
assert.ErrorIs(t, c.Upload(ctx, uuid.New(), pngBytes(t)), ErrNotFound)
|
||||||
|
entries, _ := os.ReadDir(c.dir)
|
||||||
|
assert.Len(t, entries, 1, "no temporary files are left behind")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverFromSource(t *testing.T) {
|
||||||
|
apis := newCoverAPIs(t)
|
||||||
|
store := fakedb.New()
|
||||||
|
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
c := newCovers(t, store, apis.options(true))
|
||||||
|
require.NoError(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "5"))
|
||||||
|
got := coverOf(t, store, "Game")
|
||||||
|
assert.Equal(t, "steamgriddb", *got.CoverSource)
|
||||||
|
assert.Equal(t, "5", *got.CoverExternalID)
|
||||||
|
assert.True(t, got.CoverManual, "a picked cover is never replaced by the job")
|
||||||
|
|
||||||
|
require.NoError(t, c.FromSource(ctx, st.Uuid, "anilist", "42"))
|
||||||
|
assert.Equal(t, "anilist", *coverOf(t, store, "Game").CoverSource)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "404"), ErrNotFound, "no grids")
|
||||||
|
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "abc"), ErrNotFound)
|
||||||
|
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "anilist", "7"), ErrNotFound)
|
||||||
|
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "igdb", "1"), ErrInvalidName)
|
||||||
|
assert.ErrorIs(t, c.FromSource(ctx, uuid.New(), "anilist", "42"), ErrNotFound)
|
||||||
|
|
||||||
|
withoutKey := newCovers(t, store, apis.options(false))
|
||||||
|
assert.ErrorIs(t, withoutKey.FromSource(ctx, st.Uuid, "steamgriddb", "5"), ErrInvalidName)
|
||||||
|
require.NoError(t, withoutKey.FromSource(ctx, st.Uuid, "anilist", "42"), "AniList needs no key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverDownloadMustBeAnImage(t *testing.T) {
|
||||||
|
apis := newCoverAPIs(t)
|
||||||
|
c := newCovers(t, fakedb.New(), apis.options(true))
|
||||||
|
_, err := c.download(context.Background(), apis.server.URL+"/img/text.png")
|
||||||
|
assert.ErrorIs(t, err, ErrUpstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverRemove(t *testing.T) {
|
||||||
|
store := fakedb.New()
|
||||||
|
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||||
|
c := newCovers(t, store, CoverOptions{})
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
|
||||||
|
|
||||||
|
require.NoError(t, c.Remove(ctx, st.Uuid))
|
||||||
|
|
||||||
|
got := coverOf(t, store, "Game")
|
||||||
|
assert.Nil(t, got.CoverFile)
|
||||||
|
assert.False(t, got.CoverManual)
|
||||||
|
assert.False(t, got.CoverChecked.Valid, "the job searches for it again")
|
||||||
|
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".png"))
|
||||||
|
_, err := c.Path(ctx, st.Uuid)
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
assert.Len(t, c.trigger, 1, "the job is asked to search")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverRemoveOrphans(t *testing.T) {
|
||||||
|
store := fakedb.New()
|
||||||
|
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||||
|
c := newCovers(t, store, CoverOptions{})
|
||||||
|
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
|
||||||
|
writeFile(t, filepath.Join(c.dir, uuid.NewString()+".png"), "left over after a reset")
|
||||||
|
|
||||||
|
c.removeOrphans(context.Background())
|
||||||
|
|
||||||
|
entries, _ := os.ReadDir(c.dir)
|
||||||
|
require.Len(t, entries, 1)
|
||||||
|
assert.Equal(t, st.Uuid.String()+".png", entries[0].Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverRunSearchesUntilStopped(t *testing.T) {
|
||||||
|
apis := newCoverAPIs(t)
|
||||||
|
apis.games["Game"] = 1
|
||||||
|
store := fakedb.New()
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||||
|
c := newCovers(t, store, apis.options(true))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { c.Run(ctx); close(done) }()
|
||||||
|
|
||||||
|
assert.Eventually(t, func() bool { return coverOf(t, store, "Game").CoverFile != nil }, time.Second, 10*time.Millisecond,
|
||||||
|
"searches when started")
|
||||||
|
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) // same name, new soundtrack
|
||||||
|
c.Trigger()
|
||||||
|
assert.Eventually(t, func() bool { return strings.Count(strings.Join(apis.searched(), ","), "sgdb:Game") == 2 },
|
||||||
|
time.Second, 10*time.Millisecond, "searches again when triggered")
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Run did not stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverRunWithoutKeyDoesNothing(t *testing.T) {
|
||||||
|
c := newCovers(t, fakedb.New(), CoverOptions{})
|
||||||
|
assert.False(t, c.SearchesAutomatically())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { c.Run(context.Background()); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Run should return at once without a SteamGridDB key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverSearchNames(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Lemmings (SNES + Mega Drive)", "LEM", "lemmings"},
|
||||||
|
coverSearchNames("Lemmings (SNES + Mega Drive)", []string{"LEM", "lemmings"}))
|
||||||
|
assert.Equal(t, []string{"Chrono Trigger"}, coverSearchNames("Chrono Trigger", nil))
|
||||||
|
assert.Equal(t, []string{"Resident Evil 2 [Remake]", "Resident Evil 2"}, coverSearchNames("Resident Evil 2 [Remake]", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTitle(t *testing.T) {
|
||||||
|
assert.Equal(t, normalizeTitle("Cowboy Bebop: The Movie"), normalizeTitle("cowboy bebop the movie"))
|
||||||
|
assert.Equal(t, "ファイナルファンタジーvii", normalizeTitle("ファイナルファンタジーVII"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBestGame(t *testing.T) {
|
||||||
|
games := func(names ...string) []steamGridDBGame {
|
||||||
|
result := make([]steamGridDBGame, len(names))
|
||||||
|
for i, name := range names {
|
||||||
|
result[i] = steamGridDBGame{ID: i + 1, Name: name}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
search string
|
||||||
|
games []steamGridDBGame
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Real SteamGridDB results, in their order.
|
||||||
|
{"Civilization IV", games("Sid Meier's Civilization IV: Colonization", "Sid Meier's Civilization IV",
|
||||||
|
"Sid Meier's Civilization IV: Warlords", "Galactic Civilizations IV"), "Sid Meier's Civilization IV"},
|
||||||
|
{"Lemmings 3D", games("3D Lemmings Winterland", "3D Lemmings", "Lemmings"), "3D Lemmings"},
|
||||||
|
{"Octopath Traveler 2", games("Octopath Traveler II", "Octopath Traveler"), "Octopath Traveler II"},
|
||||||
|
{"Joe & Mac", games("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), "Joe & Mac: Caveman Ninja"},
|
||||||
|
{"Ys (Chronicles)", games("Ys I & II Chronicles", "Ys Origin"), "Ys I & II Chronicles"},
|
||||||
|
// No result has every word: SteamGridDB's first result.
|
||||||
|
{"Resident Evil 2 (Remake)", games("Resident Evil 2", "Resident Evil 2: 1-Shot Demo"), "Resident Evil 2"},
|
||||||
|
{"Pokémon HeartGold & SoulSilver", games("Pokémon HeartGold Version", "Pokémon SoulSilver Version"), "Pokémon HeartGold Version"},
|
||||||
|
{"Kingdom Hearts", games("Kingdom Hearts II", "Kingdom Hearts"), "Kingdom Hearts"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, bestGame(tt.search, tt.games).Name, tt.search)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ var (
|
|||||||
ErrNotFound = errors.New("not found")
|
ErrNotFound = errors.New("not found")
|
||||||
// ErrInvalidName is returned when a requested file name is not allowed.
|
// ErrInvalidName is returned when a requested file name is not allowed.
|
||||||
ErrInvalidName = errors.New("invalid name")
|
ErrInvalidName = errors.New("invalid name")
|
||||||
|
// ErrInvalidImage is returned when an image is not a supported type or too large.
|
||||||
|
ErrInvalidImage = errors.New("invalid image")
|
||||||
|
// ErrCoversDisabled is returned when covers are used but COVERS_PATH is not set.
|
||||||
|
ErrCoversDisabled = errors.New("covers are not enabled on this server")
|
||||||
// ErrUpstream is returned when an external service could not be reached.
|
// ErrUpstream is returned when an external service could not be reached.
|
||||||
ErrUpstream = errors.New("upstream service unavailable")
|
ErrUpstream = errors.New("upstream service unavailable")
|
||||||
)
|
)
|
||||||
+68
-10
@@ -3,23 +3,30 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Soundtrack is a soundtrack as returned by the API.
|
// Soundtrack is a soundtrack as returned by the API.
|
||||||
type Soundtrack struct {
|
type Soundtrack struct {
|
||||||
Uuid uuid.UUID `json:"uuid"`
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TimesPlayed int32 `json:"times_played"`
|
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
|
||||||
LastPlayed *time.Time `json:"last_played,omitempty"`
|
TimesPlayed int32 `json:"times_played"`
|
||||||
Added time.Time `json:"added"`
|
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.
|
// SoundtrackWithSongList is a soundtrack with all its songs.
|
||||||
type SoundtrackWithSongList struct {
|
type SoundtrackWithSongList struct {
|
||||||
Soundtrack
|
Soundtrack
|
||||||
@@ -48,14 +55,65 @@ func NewLibraryService(q repository.Querier) *LibraryService {
|
|||||||
|
|
||||||
func toSoundtrack(s repository.Soundtrack) Soundtrack {
|
func toSoundtrack(s repository.Soundtrack) Soundtrack {
|
||||||
return Soundtrack{
|
return Soundtrack{
|
||||||
Uuid: s.Uuid,
|
Uuid: s.Uuid,
|
||||||
Name: s.SoundtrackName,
|
Name: s.SoundtrackName,
|
||||||
TimesPlayed: s.TimesPlayed,
|
AlternativeNames: nonNil(s.AlternativeNames),
|
||||||
LastPlayed: s.LastPlayed,
|
TimesPlayed: s.TimesPlayed,
|
||||||
Added: s.Added,
|
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 {
|
func notFound(err error) error {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"music-server/internal/testutil/fakedb"
|
"music-server/internal/testutil/fakedb"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -24,11 +25,54 @@ func TestLibrarySoundtracks(t *testing.T) {
|
|||||||
soundtracks, err := NewLibraryService(store).Soundtracks(context.Background())
|
soundtracks, err := NewLibraryService(store).Soundtracks(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, soundtracks, 2)
|
require.Len(t, soundtracks, 2)
|
||||||
assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game"}, soundtracks[0])
|
assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game", AlternativeNames: []string{}}, soundtracks[0])
|
||||||
assert.Equal(t, b.Uuid, soundtracks[1].Uuid)
|
assert.Equal(t, b.Uuid, soundtracks[1].Uuid)
|
||||||
assert.Equal(t, int32(3), soundtracks[1].TimesPlayed)
|
assert.Equal(t, int32(3), soundtracks[1].TimesPlayed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLibrarySetAlternativeNames(t *testing.T) {
|
||||||
|
store := fakedb.New()
|
||||||
|
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"})
|
||||||
|
l := NewLibraryService(store)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
soundtrack, err := l.SetAlternativeNames(ctx, st.Uuid,
|
||||||
|
[]string{" FF7 ", "ff7", "", "final fantasy vii", "ファイナルファンタジーVII", " "})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"FF7", "ファイナルファンタジーVII"}, soundtrack.AlternativeNames,
|
||||||
|
"trimmed, without empty names, duplicates or the soundtrack's own name")
|
||||||
|
|
||||||
|
stored, err := l.Soundtrack(ctx, st.Uuid)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"FF7", "ファイナルファンタジーVII"}, stored.AlternativeNames)
|
||||||
|
|
||||||
|
soundtrack, err = l.SetAlternativeNames(ctx, st.Uuid, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{}, soundtrack.AlternativeNames, "an empty list removes them")
|
||||||
|
|
||||||
|
_, err = l.SetAlternativeNames(ctx, uuid.New(), []string{"x"})
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibrarySetAlternativeNamesSearchesForACoverAgain(t *testing.T) {
|
||||||
|
store := fakedb.New()
|
||||||
|
checked := pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
||||||
|
without := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "No cover", CoverChecked: checked})
|
||||||
|
file := "cover.jpg"
|
||||||
|
with := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cover", CoverFile: &file, CoverChecked: checked})
|
||||||
|
l := NewLibraryService(store)
|
||||||
|
|
||||||
|
_, err := l.SetAlternativeNames(context.Background(), without.Uuid, []string{"x"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = l.SetAlternativeNames(context.Background(), with.Uuid, []string{"y"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
st, _ := store.Soundtrack("No cover")
|
||||||
|
assert.False(t, st.CoverChecked.Valid, "searched again with the new names")
|
||||||
|
st, _ = store.Soundtrack("Cover")
|
||||||
|
assert.True(t, st.CoverChecked.Valid, "a soundtrack with a cover keeps it")
|
||||||
|
}
|
||||||
|
|
||||||
func TestLibrarySoundtrackWithSongs(t *testing.T) {
|
func TestLibrarySoundtrackWithSongs(t *testing.T) {
|
||||||
store := fakedb.New()
|
store := fakedb.New()
|
||||||
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A")
|
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A")
|
||||||
|
|||||||
@@ -348,6 +348,19 @@ func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) {
|
|||||||
return names, nil
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchSoundtracks returns every soundtrack's name and alternative names, for search.
|
||||||
|
func (m *MusicService) SearchSoundtracks(ctx context.Context) ([]SearchSoundtrack, error) {
|
||||||
|
soundtracks, err := m.allSoundtracks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]SearchSoundtrack, len(soundtracks))
|
||||||
|
for i, s := range soundtracks {
|
||||||
|
result[i] = SearchSoundtrack{Name: s.SoundtrackName, AlternativeNames: s.AlternativeNames}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SoundtrackNamesRandom returns all soundtrack names in random order.
|
// SoundtrackNamesRandom returns all soundtrack names in random order.
|
||||||
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
|
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
|
||||||
names, err := m.SoundtrackNames(ctx)
|
names, err := m.SoundtrackNames(ctx)
|
||||||
|
|||||||
@@ -6,23 +6,40 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SoundtrackData holds precomputed search data for a soundtrack
|
// SearchSoundtrack is a soundtrack's name and alternative names, as loaded for search.
|
||||||
type SoundtrackData struct {
|
type SearchSoundtrack struct {
|
||||||
|
Name string
|
||||||
|
AlternativeNames []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchName holds a name prepared for matching.
|
||||||
|
type SearchName struct {
|
||||||
Original string
|
Original string
|
||||||
Lowercase string
|
Lowercase string
|
||||||
Cleaned string
|
Cleaned string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchCache holds precomputed search data for all soundtrack names.
|
func newSearchName(name string) SearchName {
|
||||||
|
return SearchName{Original: name, Lowercase: strings.ToLower(name), Cleaned: CleanSearchTerm(name)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoundtrackData holds precomputed search data for a soundtrack: its name and its
|
||||||
|
// alternative names.
|
||||||
|
type SoundtrackData struct {
|
||||||
|
SearchName
|
||||||
|
Alternatives []SearchName
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchCache holds precomputed search data for all soundtracks.
|
||||||
type SearchCache struct {
|
type SearchCache struct {
|
||||||
load func(context.Context) ([]string, error)
|
load func(context.Context) ([]SearchSoundtrack, error)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cache []SoundtrackData
|
cache []SoundtrackData
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSearchCache creates a SearchCache that gets the soundtrack names from load.
|
// NewSearchCache creates a SearchCache that gets the soundtracks from load.
|
||||||
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache {
|
func NewSearchCache(load func(context.Context) ([]SearchSoundtrack, error)) *SearchCache {
|
||||||
return &SearchCache{load: load}
|
return &SearchCache{load: load}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,10 +69,9 @@ func (s *SearchCache) reload(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
cache := make([]SoundtrackData, len(soundtracks))
|
cache := make([]SoundtrackData, len(soundtracks))
|
||||||
for i, soundtrack := range soundtracks {
|
for i, soundtrack := range soundtracks {
|
||||||
cache[i] = SoundtrackData{
|
cache[i] = SoundtrackData{SearchName: newSearchName(soundtrack.Name)}
|
||||||
Original: soundtrack,
|
for _, name := range soundtrack.AlternativeNames {
|
||||||
Lowercase: strings.ToLower(soundtrack),
|
cache[i].Alternatives = append(cache[i].Alternatives, newSearchName(name))
|
||||||
Cleaned: CleanSearchTerm(soundtrack),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.cache = cache
|
s.cache = cache
|
||||||
|
|||||||
@@ -40,28 +40,31 @@ func TestMatchesSubsequence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchCache(t *testing.T) {
|
func TestSearchCache(t *testing.T) {
|
||||||
names := []string{"Final Fantasy VII"}
|
soundtracks := []SearchSoundtrack{{Name: "Final Fantasy VII", AlternativeNames: []string{"FF 7"}}}
|
||||||
calls := 0
|
calls := 0
|
||||||
cache := NewSearchCache(func(context.Context) ([]string, error) {
|
cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) {
|
||||||
calls++
|
calls++
|
||||||
return names, nil
|
return soundtracks, nil
|
||||||
})
|
})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
data, err := cache.Get(ctx)
|
data, err := cache.Get(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []SoundtrackData{{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"}}, data)
|
assert.Equal(t, []SoundtrackData{{
|
||||||
|
SearchName: SearchName{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"},
|
||||||
|
Alternatives: []SearchName{{Original: "FF 7", Lowercase: "ff 7", Cleaned: "ff7"}},
|
||||||
|
}}, data)
|
||||||
_, _ = cache.Get(ctx)
|
_, _ = cache.Get(ctx)
|
||||||
assert.Equal(t, 1, calls, "data is cached")
|
assert.Equal(t, 1, calls, "data is cached")
|
||||||
|
|
||||||
names = []string{"Chrono Trigger"}
|
soundtracks = []SearchSoundtrack{{Name: "Chrono Trigger"}}
|
||||||
require.NoError(t, cache.Reload(ctx))
|
require.NoError(t, cache.Reload(ctx))
|
||||||
data, _ = cache.Get(ctx)
|
data, _ = cache.Get(ctx)
|
||||||
assert.Equal(t, "Chrono Trigger", data[0].Original)
|
assert.Equal(t, "Chrono Trigger", data[0].Original)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchCacheLoadError(t *testing.T) {
|
func TestSearchCacheLoadError(t *testing.T) {
|
||||||
cache := NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") })
|
cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) { return nil, errors.New("db down") })
|
||||||
_, err := cache.Get(context.Background())
|
_, err := cache.Get(context.Background())
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
@@ -15,8 +15,10 @@ type SoundtrackWithSongs struct {
|
|||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
|
||||||
|
CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."`
|
||||||
Songs []SongInfoForStats `json:"songs"`
|
Songs []SongInfoForStats `json:"songs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,8 +76,10 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs,
|
|||||||
SoundtrackID: row.SoundtrackID,
|
SoundtrackID: row.SoundtrackID,
|
||||||
SoundtrackUuid: row.SoundtrackUuid,
|
SoundtrackUuid: row.SoundtrackUuid,
|
||||||
SoundtrackName: row.SoundtrackName,
|
SoundtrackName: row.SoundtrackName,
|
||||||
|
AlternativeNames: nonNil(row.AlternativeNames),
|
||||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||||
|
CoverModified: unixSeconds(row.CoverModified),
|
||||||
Songs: parseSongs(row.Songs),
|
Songs: parseSongs(row.Songs),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -144,7 +148,9 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
|
|||||||
SoundtrackID: row.SoundtrackID,
|
SoundtrackID: row.SoundtrackID,
|
||||||
SoundtrackUuid: row.SoundtrackUuid,
|
SoundtrackUuid: row.SoundtrackUuid,
|
||||||
SoundtrackName: row.SoundtrackName,
|
SoundtrackName: row.SoundtrackName,
|
||||||
|
AlternativeNames: nonNil(row.AlternativeNames),
|
||||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
|
CoverModified: unixSeconds(row.CoverModified),
|
||||||
Songs: parseSongs(row.Songs),
|
Songs: parseSongs(row.Songs),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,3 +403,34 @@ func TestResetDB(t *testing.T) {
|
|||||||
func TestFormatDuration(t *testing.T) {
|
func TestFormatDuration(t *testing.T) {
|
||||||
assert.Equal(t, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second))
|
assert.Equal(t, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncKeepsAlternativeNamesAndCover(t *testing.T) {
|
||||||
|
f := newSyncFixture(t)
|
||||||
|
f.write("Final Fantasy 7/01.mp3", "a1")
|
||||||
|
f.run(false)
|
||||||
|
st := f.soundtrack("Final Fantasy 7")
|
||||||
|
_, err := f.store.SetAlternativeNames(context.Background(),
|
||||||
|
repository.SetAlternativeNamesParams{Uuid: st.Uuid, Names: []string{"FF7"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
file, source := "cover.jpg", "upload"
|
||||||
|
require.NoError(t, f.store.SetCover(context.Background(),
|
||||||
|
repository.SetCoverParams{Uuid: st.Uuid, File: &file, Source: &source, Manual: true}))
|
||||||
|
|
||||||
|
// Renamed: the same row is kept.
|
||||||
|
require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy 7"), filepath.Join(f.dir, "Final Fantasy VII")))
|
||||||
|
f.run(false)
|
||||||
|
renamed := f.soundtrack("Final Fantasy VII")
|
||||||
|
assert.Equal(t, st.Uuid, renamed.Uuid)
|
||||||
|
assert.Equal(t, []string{"FF7"}, renamed.AlternativeNames)
|
||||||
|
assert.Equal(t, &file, renamed.CoverFile)
|
||||||
|
|
||||||
|
// Removed and added again.
|
||||||
|
require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy VII"), filepath.Join(t.TempDir(), "away")))
|
||||||
|
f.run(false)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(f.dir, "Final Fantasy VII"), 0o755))
|
||||||
|
f.write("Final Fantasy VII/01.mp3", "a1")
|
||||||
|
f.run(false)
|
||||||
|
readded := f.soundtrack("Final Fantasy VII")
|
||||||
|
assert.Nil(t, readded.Deleted)
|
||||||
|
assert.Equal(t, []string{"FF7"}, readded.AlternativeNames)
|
||||||
|
}
|
||||||
@@ -31,6 +31,14 @@ type Config struct {
|
|||||||
CharactersPath string
|
CharactersPath string
|
||||||
SoundCheckPath string
|
SoundCheckPath string
|
||||||
FrontendPath string
|
FrontendPath string
|
||||||
|
// CoversPath is the folder for soundtrack covers. Empty turns covers off.
|
||||||
|
CoversPath string
|
||||||
|
|
||||||
|
// SteamGridDBAPIKey turns on the background search for covers.
|
||||||
|
SteamGridDBAPIKey string
|
||||||
|
// SteamGridDBURL and AniListURL replace the real cover APIs; only tests set them.
|
||||||
|
SteamGridDBURL string
|
||||||
|
AniListURL string
|
||||||
|
|
||||||
LogLevel string
|
LogLevel string
|
||||||
LogJSON bool
|
LogJSON bool
|
||||||
@@ -65,6 +73,8 @@ func load(getenv func(string) string) (Config, error) {
|
|||||||
CharactersPath: getenv("CHARACTERS_PATH"),
|
CharactersPath: getenv("CHARACTERS_PATH"),
|
||||||
SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"),
|
SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"),
|
||||||
FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"),
|
FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"),
|
||||||
|
CoversPath: getenv("COVERS_PATH"),
|
||||||
|
SteamGridDBAPIKey: getenv("STEAMGRIDDB_API_KEY"),
|
||||||
LogLevel: withDefault(getenv("LOG_LEVEL"), "info"),
|
LogLevel: withDefault(getenv("LOG_LEVEL"), "info"),
|
||||||
LogJSON: getenv("LOG_JSON") == "true",
|
LogJSON: getenv("LOG_JSON") == "true",
|
||||||
APIPassword: getenv("API_PASSWORD"),
|
APIPassword: getenv("API_PASSWORD"),
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
assert.Empty(t, cfg.TrustedProxies)
|
assert.Empty(t, cfg.TrustedProxies)
|
||||||
assert.Empty(t, cfg.CORSAllowedOrigins)
|
assert.Empty(t, cfg.CORSAllowedOrigins)
|
||||||
assert.Equal(t, DefaultMetricsPort, cfg.MetricsPort)
|
assert.Equal(t, DefaultMetricsPort, cfg.MetricsPort)
|
||||||
|
assert.Empty(t, cfg.CoversPath, "covers are off by default")
|
||||||
|
assert.Empty(t, cfg.SteamGridDBAPIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCovers(t *testing.T) {
|
||||||
|
values := validEnv()
|
||||||
|
values["COVERS_PATH"] = "/covers"
|
||||||
|
values["STEAMGRIDDB_API_KEY"] = "key"
|
||||||
|
cfg, err := load(env(values))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/covers", cfg.CoversPath)
|
||||||
|
assert.Equal(t, "key", cfg.SteamGridDBAPIKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadMetricsPort(t *testing.T) {
|
func TestLoadMetricsPort(t *testing.T) {
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
ALTER TABLE soundtrack
|
||||||
|
DROP COLUMN alternative_names,
|
||||||
|
DROP COLUMN cover_file,
|
||||||
|
DROP COLUMN cover_source,
|
||||||
|
DROP COLUMN cover_external_id,
|
||||||
|
DROP COLUMN cover_manual,
|
||||||
|
DROP COLUMN cover_modified,
|
||||||
|
DROP COLUMN cover_checked;
|
||||||
DROP TABLE queue_song;
|
DROP TABLE queue_song;
|
||||||
DROP TABLE queue;
|
DROP TABLE queue;
|
||||||
ALTER TABLE soundtrack DROP COLUMN fingerprint;
|
ALTER TABLE soundtrack DROP COLUMN fingerprint;
|
||||||
@@ -27,3 +27,17 @@ CREATE TABLE queue_song (
|
|||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
PRIMARY KEY (queue_uuid, song_no)
|
PRIMARY KEY (queue_uuid, song_no)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Other names a soundtrack is known by, for search and the cover search.
|
||||||
|
ALTER TABLE soundtrack ADD COLUMN alternative_names TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Cover art. cover_file is the image's file name in COVERS_PATH, NULL when there is no
|
||||||
|
-- cover. Manual covers (uploaded or picked) are never replaced by the background job.
|
||||||
|
-- cover_checked is when the job last searched, so soundtracks without a match are not
|
||||||
|
-- searched again for a while.
|
||||||
|
ALTER TABLE soundtrack ADD COLUMN cover_file TEXT NULL;
|
||||||
|
ALTER TABLE soundtrack ADD COLUMN cover_source VARCHAR(20) NULL;
|
||||||
|
ALTER TABLE soundtrack ADD COLUMN cover_external_id TEXT NULL;
|
||||||
|
ALTER TABLE soundtrack ADD COLUMN cover_manual BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE soundtrack ADD COLUMN cover_modified TIMESTAMPTZ NULL;
|
||||||
|
ALTER TABLE soundtrack ADD COLUMN cover_checked TIMESTAMPTZ NULL;
|
||||||
@@ -65,3 +65,44 @@ SELECT uuid FROM soundtrack WHERE id = $1;
|
|||||||
|
|
||||||
-- name: AddSoundtrackPlayedByUUID :exec
|
-- name: AddSoundtrackPlayedByUUID :exec
|
||||||
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1;
|
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1;
|
||||||
|
|
||||||
|
-- name: SetAlternativeNames :one
|
||||||
|
-- Replaces the alternative names. A soundtrack without a cover is searched again with them.
|
||||||
|
UPDATE soundtrack
|
||||||
|
SET alternative_names = sqlc.arg(names)::text[],
|
||||||
|
cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END
|
||||||
|
WHERE uuid = sqlc.arg(uuid) AND deleted IS NULL
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListSoundtracksNeedingCover :many
|
||||||
|
-- Active soundtracks without a cover that the job has not searched since checked_before.
|
||||||
|
SELECT uuid, soundtrack_name, alternative_names
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE deleted IS NULL
|
||||||
|
AND cover_file IS NULL
|
||||||
|
AND NOT cover_manual
|
||||||
|
AND (cover_checked IS NULL OR cover_checked < sqlc.arg(checked_before))
|
||||||
|
ORDER BY soundtrack_name;
|
||||||
|
|
||||||
|
-- name: SetCover :exec
|
||||||
|
UPDATE soundtrack
|
||||||
|
SET cover_file = sqlc.arg(file),
|
||||||
|
cover_source = sqlc.arg(source),
|
||||||
|
cover_external_id = sqlc.arg(external_id),
|
||||||
|
cover_manual = sqlc.arg(manual),
|
||||||
|
cover_modified = now(),
|
||||||
|
cover_checked = now()
|
||||||
|
WHERE uuid = sqlc.arg(uuid);
|
||||||
|
|
||||||
|
-- name: MarkCoverChecked :exec
|
||||||
|
UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1;
|
||||||
|
|
||||||
|
-- name: ClearCover :exec
|
||||||
|
-- Removes the cover, so the job searches for one again.
|
||||||
|
UPDATE soundtrack
|
||||||
|
SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false,
|
||||||
|
cover_modified = NULL, cover_checked = NULL
|
||||||
|
WHERE uuid = $1;
|
||||||
|
|
||||||
|
-- name: ListCoverFiles :many
|
||||||
|
SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL;
|
||||||
@@ -5,6 +5,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -33,6 +35,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -95,6 +99,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.added,
|
g.added,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -121,6 +127,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -148,6 +156,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -188,6 +198,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
|
|||||||
@@ -59,18 +59,25 @@ type SongList struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Soundtrack struct {
|
type Soundtrack struct {
|
||||||
ID int32 `json:"id"`
|
ID int32 `json:"id"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
Added time.Time `json:"added"`
|
Added time.Time `json:"added"`
|
||||||
Deleted *time.Time `json:"deleted"`
|
Deleted *time.Time `json:"deleted"`
|
||||||
LastChanged *time.Time `json:"last_changed"`
|
LastChanged *time.Time `json:"last_changed"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
TimesPlayed int32 `json:"times_played"`
|
TimesPlayed int32 `json:"times_played"`
|
||||||
LastPlayed *time.Time `json:"last_played"`
|
LastPlayed *time.Time `json:"last_played"`
|
||||||
NumberOfSongs int32 `json:"number_of_songs"`
|
NumberOfSongs int32 `json:"number_of_songs"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Uuid uuid.UUID `json:"uuid"`
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
Fingerprint string `json:"fingerprint"`
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
AlternativeNames []string `json:"alternative_names"`
|
||||||
|
CoverFile *string `json:"cover_file"`
|
||||||
|
CoverSource *string `json:"cover_source"`
|
||||||
|
CoverExternalID *string `json:"cover_external_id"`
|
||||||
|
CoverManual bool `json:"cover_manual"`
|
||||||
|
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||||
|
CoverChecked pgtype.Timestamptz `json:"cover_checked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Vgmq struct {
|
type Vgmq struct {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Querier interface {
|
type Querier interface {
|
||||||
@@ -23,6 +24,8 @@ type Querier interface {
|
|||||||
AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
|
AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
|
||||||
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
|
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
|
||||||
CheckSongWithHash(ctx context.Context, hash string) (int64, error)
|
CheckSongWithHash(ctx context.Context, hash string) (int64, error)
|
||||||
|
// Removes the cover, so the job searches for one again.
|
||||||
|
ClearCover(ctx context.Context, argUuid uuid.UUID) error
|
||||||
ClearSongs(ctx context.Context) error
|
ClearSongs(ctx context.Context) error
|
||||||
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
|
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
|
||||||
ClearSoundtracks(ctx context.Context) error
|
ClearSoundtracks(ctx context.Context) error
|
||||||
@@ -73,14 +76,21 @@ type Querier interface {
|
|||||||
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
|
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
|
||||||
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
|
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
|
||||||
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error
|
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error
|
||||||
|
ListCoverFiles(ctx context.Context) ([]string, error)
|
||||||
// The song columns are NULL when the song has been removed from the library since.
|
// The song columns are NULL when the song has been removed from the library since.
|
||||||
ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error)
|
ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error)
|
||||||
ListSessions(ctx context.Context) ([]Session, error)
|
ListSessions(ctx context.Context) ([]Session, error)
|
||||||
|
// Active soundtracks without a cover that the job has not searched since checked_before.
|
||||||
|
ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error)
|
||||||
|
MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error
|
||||||
MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
|
MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
|
||||||
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
|
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
|
||||||
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
|
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
|
||||||
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
|
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
|
||||||
ResetSoundtrackIdSeq(ctx context.Context) (int64, error)
|
ResetSoundtrackIdSeq(ctx context.Context) (int64, error)
|
||||||
|
// Replaces the alternative names. A soundtrack without a cover is searched again with them.
|
||||||
|
SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error)
|
||||||
|
SetCover(ctx context.Context, arg SetCoverParams) error
|
||||||
SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error
|
SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error
|
||||||
SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error
|
SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error
|
||||||
SetSoundtrackDeletionDate(ctx context.Context) error
|
SetSoundtrackDeletionDate(ctx context.Context) error
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
|
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
|
||||||
@@ -29,6 +30,19 @@ func (q *Queries) AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UU
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearCover = `-- name: ClearCover :exec
|
||||||
|
UPDATE soundtrack
|
||||||
|
SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false,
|
||||||
|
cover_modified = NULL, cover_checked = NULL
|
||||||
|
WHERE uuid = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Removes the cover, so the job searches for one again.
|
||||||
|
func (q *Queries) ClearCover(ctx context.Context, argUuid uuid.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, clearCover, argUuid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const clearSoundtracks = `-- name: ClearSoundtracks :exec
|
const clearSoundtracks = `-- name: ClearSoundtracks :exec
|
||||||
DELETE FROM soundtrack
|
DELETE FROM soundtrack
|
||||||
`
|
`
|
||||||
@@ -39,7 +53,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
|
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
|
||||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||||
FROM soundtrack
|
FROM soundtrack
|
||||||
WHERE deleted IS NULL
|
WHERE deleted IS NULL
|
||||||
ORDER BY soundtrack_name
|
ORDER BY soundtrack_name
|
||||||
@@ -67,6 +81,13 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
|||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
&i.Fingerprint,
|
&i.Fingerprint,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverFile,
|
||||||
|
&i.CoverSource,
|
||||||
|
&i.CoverExternalID,
|
||||||
|
&i.CoverManual,
|
||||||
|
&i.CoverModified,
|
||||||
|
&i.CoverChecked,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -79,7 +100,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
|
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
|
||||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||||
FROM soundtrack
|
FROM soundtrack
|
||||||
ORDER BY soundtrack_name
|
ORDER BY soundtrack_name
|
||||||
`
|
`
|
||||||
@@ -106,6 +127,13 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
|
|||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
&i.Fingerprint,
|
&i.Fingerprint,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverFile,
|
||||||
|
&i.CoverSource,
|
||||||
|
&i.CoverExternalID,
|
||||||
|
&i.CoverManual,
|
||||||
|
&i.CoverModified,
|
||||||
|
&i.CoverChecked,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -129,7 +157,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getSoundtrackById = `-- name: GetSoundtrackById :one
|
const getSoundtrackById = `-- name: GetSoundtrackById :one
|
||||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||||
FROM soundtrack
|
FROM soundtrack
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND deleted IS NULL
|
AND deleted IS NULL
|
||||||
@@ -151,12 +179,19 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
|
|||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
&i.Fingerprint,
|
&i.Fingerprint,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverFile,
|
||||||
|
&i.CoverSource,
|
||||||
|
&i.CoverExternalID,
|
||||||
|
&i.CoverManual,
|
||||||
|
&i.CoverModified,
|
||||||
|
&i.CoverChecked,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
|
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
|
||||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||||
FROM soundtrack
|
FROM soundtrack
|
||||||
WHERE uuid = $1
|
WHERE uuid = $1
|
||||||
AND deleted IS NULL
|
AND deleted IS NULL
|
||||||
@@ -178,6 +213,13 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
|
|||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
&i.Fingerprint,
|
&i.Fingerprint,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverFile,
|
||||||
|
&i.CoverSource,
|
||||||
|
&i.CoverExternalID,
|
||||||
|
&i.CoverManual,
|
||||||
|
&i.CoverModified,
|
||||||
|
&i.CoverChecked,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -253,6 +295,76 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listCoverFiles = `-- name: ListCoverFiles :many
|
||||||
|
SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListCoverFiles(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listCoverFiles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []string
|
||||||
|
for rows.Next() {
|
||||||
|
var cover_file string
|
||||||
|
if err := rows.Scan(&cover_file); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, cover_file)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listSoundtracksNeedingCover = `-- name: ListSoundtracksNeedingCover :many
|
||||||
|
SELECT uuid, soundtrack_name, alternative_names
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE deleted IS NULL
|
||||||
|
AND cover_file IS NULL
|
||||||
|
AND NOT cover_manual
|
||||||
|
AND (cover_checked IS NULL OR cover_checked < $1)
|
||||||
|
ORDER BY soundtrack_name
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListSoundtracksNeedingCoverRow struct {
|
||||||
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
AlternativeNames []string `json:"alternative_names"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active soundtracks without a cover that the job has not searched since checked_before.
|
||||||
|
func (q *Queries) ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listSoundtracksNeedingCover, checkedBefore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListSoundtracksNeedingCoverRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListSoundtracksNeedingCoverRow
|
||||||
|
if err := rows.Scan(&i.Uuid, &i.SoundtrackName, &i.AlternativeNames); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const markCoverChecked = `-- name: MarkCoverChecked :exec
|
||||||
|
UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, markCoverChecked, argUuid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
|
const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
|
||||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[]))
|
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[]))
|
||||||
`
|
`
|
||||||
@@ -282,6 +394,77 @@ func (q *Queries) ResetSoundtrackIdSeq(ctx context.Context) (int64, error) {
|
|||||||
return setval, err
|
return setval, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setAlternativeNames = `-- name: SetAlternativeNames :one
|
||||||
|
UPDATE soundtrack
|
||||||
|
SET alternative_names = $1::text[],
|
||||||
|
cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END
|
||||||
|
WHERE uuid = $2 AND deleted IS NULL
|
||||||
|
RETURNING id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetAlternativeNamesParams struct {
|
||||||
|
Names []string `json:"names"`
|
||||||
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaces the alternative names. A soundtrack without a cover is searched again with them.
|
||||||
|
func (q *Queries) SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error) {
|
||||||
|
row := q.db.QueryRow(ctx, setAlternativeNames, arg.Names, arg.Uuid)
|
||||||
|
var i Soundtrack
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.Added,
|
||||||
|
&i.Deleted,
|
||||||
|
&i.LastChanged,
|
||||||
|
&i.Path,
|
||||||
|
&i.TimesPlayed,
|
||||||
|
&i.LastPlayed,
|
||||||
|
&i.NumberOfSongs,
|
||||||
|
&i.Hash,
|
||||||
|
&i.Uuid,
|
||||||
|
&i.Fingerprint,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverFile,
|
||||||
|
&i.CoverSource,
|
||||||
|
&i.CoverExternalID,
|
||||||
|
&i.CoverManual,
|
||||||
|
&i.CoverModified,
|
||||||
|
&i.CoverChecked,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCover = `-- name: SetCover :exec
|
||||||
|
UPDATE soundtrack
|
||||||
|
SET cover_file = $1,
|
||||||
|
cover_source = $2,
|
||||||
|
cover_external_id = $3,
|
||||||
|
cover_manual = $4,
|
||||||
|
cover_modified = now(),
|
||||||
|
cover_checked = now()
|
||||||
|
WHERE uuid = $5
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetCoverParams struct {
|
||||||
|
File *string `json:"file"`
|
||||||
|
Source *string `json:"source"`
|
||||||
|
ExternalID *string `json:"external_id"`
|
||||||
|
Manual bool `json:"manual"`
|
||||||
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetCover(ctx context.Context, arg SetCoverParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, setCover,
|
||||||
|
arg.File,
|
||||||
|
arg.Source,
|
||||||
|
arg.ExternalID,
|
||||||
|
arg.Manual,
|
||||||
|
arg.Uuid,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec
|
const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec
|
||||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL
|
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
const countLibrary = `-- name: CountLibrary :one
|
const countLibrary = `-- name: CountLibrary :one
|
||||||
@@ -37,6 +38,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -59,12 +62,14 @@ ORDER BY g.soundtrack_name
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetAllSoundtracksWithSongsRow struct {
|
type GetAllSoundtracksWithSongsRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
AlternativeNames []string `json:"alternative_names"`
|
||||||
Songs []byte `json:"songs"`
|
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every soundtrack with its songs, by name
|
// Every soundtrack with its songs, by name
|
||||||
@@ -82,6 +87,8 @@ func (q *Queries) GetAllSoundtracksWithSongs(ctx context.Context) ([]GetAllSound
|
|||||||
&i.SoundtrackUuid,
|
&i.SoundtrackUuid,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
&i.SoundtrackPlayed,
|
&i.SoundtrackPlayed,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverModified,
|
||||||
&i.SoundtrackLastPlayed,
|
&i.SoundtrackLastPlayed,
|
||||||
&i.Songs,
|
&i.Songs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -101,6 +108,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -123,12 +132,14 @@ LIMIT $1
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetLastPlayedSoundtracksRow struct {
|
type GetLastPlayedSoundtracksRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
AlternativeNames []string `json:"alternative_names"`
|
||||||
Songs []byte `json:"songs"`
|
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last played soundtracks (most recently played)
|
// Last played soundtracks (most recently played)
|
||||||
@@ -146,6 +157,8 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
|
|||||||
&i.SoundtrackUuid,
|
&i.SoundtrackUuid,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
&i.SoundtrackPlayed,
|
&i.SoundtrackPlayed,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverModified,
|
||||||
&i.SoundtrackLastPlayed,
|
&i.SoundtrackLastPlayed,
|
||||||
&i.Songs,
|
&i.Songs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -223,6 +236,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -246,12 +261,14 @@ LIMIT $1
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetLeastPlayedSoundtracksWithSongsRow struct {
|
type GetLeastPlayedSoundtracksWithSongsRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
AlternativeNames []string `json:"alternative_names"`
|
||||||
Songs []byte `json:"songs"`
|
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Least played soundtracks with their songs
|
// Least played soundtracks with their songs
|
||||||
@@ -269,6 +286,8 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
|
|||||||
&i.SoundtrackUuid,
|
&i.SoundtrackUuid,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
&i.SoundtrackPlayed,
|
&i.SoundtrackPlayed,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverModified,
|
||||||
&i.SoundtrackLastPlayed,
|
&i.SoundtrackLastPlayed,
|
||||||
&i.Songs,
|
&i.Songs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -346,6 +365,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -369,12 +390,14 @@ LIMIT $1
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetMostPlayedSoundtracksWithSongsRow struct {
|
type GetMostPlayedSoundtracksWithSongsRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
AlternativeNames []string `json:"alternative_names"`
|
||||||
Songs []byte `json:"songs"`
|
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Most played soundtracks with their songs
|
// Most played soundtracks with their songs
|
||||||
@@ -392,6 +415,8 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
|
|||||||
&i.SoundtrackUuid,
|
&i.SoundtrackUuid,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
&i.SoundtrackPlayed,
|
&i.SoundtrackPlayed,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverModified,
|
||||||
&i.SoundtrackLastPlayed,
|
&i.SoundtrackLastPlayed,
|
||||||
&i.Songs,
|
&i.Songs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -411,6 +436,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.added,
|
g.added,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -432,12 +459,14 @@ ORDER BY g.soundtrack_name
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetNeverPlayedSoundtracksRow struct {
|
type GetNeverPlayedSoundtracksRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
Added time.Time `json:"added"`
|
AlternativeNames []string `json:"alternative_names"`
|
||||||
Songs []byte `json:"songs"`
|
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||||
|
Added time.Time `json:"added"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soundtracks that have never been played (times_played = 0)
|
// Soundtracks that have never been played (times_played = 0)
|
||||||
@@ -455,6 +484,8 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
|
|||||||
&i.SoundtrackUuid,
|
&i.SoundtrackUuid,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
&i.SoundtrackPlayed,
|
&i.SoundtrackPlayed,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverModified,
|
||||||
&i.Added,
|
&i.Added,
|
||||||
&i.Songs,
|
&i.Songs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -474,6 +505,8 @@ SELECT
|
|||||||
g.uuid as soundtrack_uuid,
|
g.uuid as soundtrack_uuid,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
g.times_played as soundtrack_played,
|
g.times_played as soundtrack_played,
|
||||||
|
g.alternative_names,
|
||||||
|
g.cover_modified,
|
||||||
g.last_played as soundtrack_last_played,
|
g.last_played as soundtrack_last_played,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -496,12 +529,14 @@ LIMIT $1
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetOldestPlayedSoundtracksRow struct {
|
type GetOldestPlayedSoundtracksRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
AlternativeNames []string `json:"alternative_names"`
|
||||||
Songs []byte `json:"songs"`
|
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Oldest played soundtracks (least recently played, but has been played at least once)
|
// Oldest played soundtracks (least recently played, but has been played at least once)
|
||||||
@@ -519,6 +554,8 @@ func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) (
|
|||||||
&i.SoundtrackUuid,
|
&i.SoundtrackUuid,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
&i.SoundtrackPlayed,
|
&i.SoundtrackPlayed,
|
||||||
|
&i.AlternativeNames,
|
||||||
|
&i.CoverModified,
|
||||||
&i.SoundtrackLastPlayed,
|
&i.SoundtrackLastPlayed,
|
||||||
&i.Songs,
|
&i.Songs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -146,8 +146,10 @@ func apiError(err error) error {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
|
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
|
||||||
return huma.Error404NotFound(err.Error())
|
return huma.Error404NotFound(err.Error())
|
||||||
case errors.Is(err, backend.ErrInvalidName):
|
case errors.Is(err, backend.ErrInvalidName), errors.Is(err, backend.ErrInvalidImage):
|
||||||
return huma.Error400BadRequest(err.Error())
|
return huma.Error400BadRequest(err.Error())
|
||||||
|
case errors.Is(err, backend.ErrCoversDisabled):
|
||||||
|
return huma.Error503ServiceUnavailable(err.Error())
|
||||||
case errors.Is(err, backend.ErrUpstream):
|
case errors.Is(err, backend.ErrUpstream):
|
||||||
return huma.Error502BadGateway(err.Error())
|
return huma.Error502BadGateway(err.Error())
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type coverUploadInput struct {
|
||||||
|
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
|
||||||
|
RawBody []byte `contentType:"image/*" doc:"The image: JPEG, PNG or WebP, at most 10 MB"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverSource is the body of PUT /soundtracks/{uuid}/cover/source.
|
||||||
|
type CoverSource struct {
|
||||||
|
Source string `json:"source" enum:"steamgriddb,anilist" doc:"Where the cover comes from"`
|
||||||
|
ID string `json:"id" minLength:"1" maxLength:"50" doc:"The game's ID on SteamGridDB, or the anime's ID on AniList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type coverSourceInput struct {
|
||||||
|
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
|
||||||
|
Body CoverSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) registerCovers(a huma.API) {
|
||||||
|
op := func(id, method, path, summary, description string, errs ...int) huma.Operation {
|
||||||
|
return huma.Operation{
|
||||||
|
OperationID: id,
|
||||||
|
Method: method,
|
||||||
|
Path: path,
|
||||||
|
Summary: summary,
|
||||||
|
Description: description,
|
||||||
|
Tags: []string{"covers"},
|
||||||
|
Errors: errs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// covers returns the cover service, or ErrCoversDisabled when COVERS_PATH is not set.
|
||||||
|
covers := func() (*backend.CoverService, error) {
|
||||||
|
if s.covers == nil {
|
||||||
|
return nil, apiError(backend.ErrCoversDisabled)
|
||||||
|
}
|
||||||
|
return s.covers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
get := op("get-soundtrack-cover", http.MethodGet, "/soundtracks/{uuid}/cover", "Soundtrack cover",
|
||||||
|
"Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.",
|
||||||
|
http.StatusNotFound)
|
||||||
|
get.Responses = fileResponses("The cover image", "image/jpeg", "image/png", "image/webp")
|
||||||
|
register(s, a, route{op: get},
|
||||||
|
func(ctx context.Context, in *uuidInput) (*huma.StreamResponse, error) {
|
||||||
|
if s.covers == nil {
|
||||||
|
return nil, huma.Error404NotFound("Not Found")
|
||||||
|
}
|
||||||
|
path, err := s.covers.Path(ctx, in.id())
|
||||||
|
if err != nil {
|
||||||
|
return nil, apiError(err)
|
||||||
|
}
|
||||||
|
return streamFile(path, "", false)
|
||||||
|
})
|
||||||
|
|
||||||
|
upload := op("upload-soundtrack-cover", http.MethodPut, "/soundtracks/{uuid}/cover", "Upload a cover",
|
||||||
|
"Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.",
|
||||||
|
http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)
|
||||||
|
upload.MaxBodyBytes = backend.MaxCoverBytes
|
||||||
|
upload.DefaultStatus = http.StatusNoContent
|
||||||
|
register(s, a, route{op: upload},
|
||||||
|
func(ctx context.Context, in *coverUploadInput) (*struct{}, error) {
|
||||||
|
c, err := covers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.Upload(ctx, uuid.MustParse(in.UUID), in.RawBody); err != nil {
|
||||||
|
return nil, apiError(err)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
source := op("set-soundtrack-cover-source", http.MethodPut, "/soundtracks/{uuid}/cover/source", "Pick a cover",
|
||||||
|
"Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. "+
|
||||||
|
"The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.",
|
||||||
|
http.StatusBadRequest, http.StatusNotFound, http.StatusBadGateway, http.StatusServiceUnavailable)
|
||||||
|
source.DefaultStatus = http.StatusNoContent
|
||||||
|
register(s, a, route{op: source},
|
||||||
|
func(ctx context.Context, in *coverSourceInput) (*struct{}, error) {
|
||||||
|
c, err := covers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.FromSource(ctx, uuid.MustParse(in.UUID), in.Body.Source, in.Body.ID); err != nil {
|
||||||
|
return nil, apiError(err)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
remove := op("delete-soundtrack-cover", http.MethodDelete, "/soundtracks/{uuid}/cover", "Remove the cover",
|
||||||
|
"Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.",
|
||||||
|
http.StatusNotFound, http.StatusServiceUnavailable)
|
||||||
|
remove.DefaultStatus = http.StatusNoContent
|
||||||
|
register(s, a, route{op: remove},
|
||||||
|
func(ctx context.Context, in *uuidInput) (*struct{}, error) {
|
||||||
|
c, err := covers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.Remove(ctx, in.id()); err != nil {
|
||||||
|
return nil, apiError(err)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pngImage(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3))))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFakeAniList answers AniList lookups by ID: ID 42 has a cover, others do not.
|
||||||
|
func newFakeAniList(t *testing.T) string {
|
||||||
|
img := pngImage(t)
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
_, _ = w.Write(img)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Variables struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
} `json:"variables"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
media := any(nil)
|
||||||
|
if req.Variables.ID == 42 {
|
||||||
|
media = map[string]any{"id": 42, "coverImage": map[string]any{"extraLarge": server.URL + "/cover.png"}}
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": media}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func withCovers(t *testing.T) func(*config.Config) {
|
||||||
|
aniList := newFakeAniList(t)
|
||||||
|
dir := filepath.Join(t.TempDir(), "covers")
|
||||||
|
return func(c *config.Config) {
|
||||||
|
c.CoversPath = dir
|
||||||
|
c.AniListURL = aniList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testEnv) put(path, contentType string, body []byte) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.handler.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverUploadAndGet(t *testing.T) {
|
||||||
|
e := newTestEnv(t, withCovers(t))
|
||||||
|
song := e.addSong("Game A", "Song", "x")
|
||||||
|
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||||
|
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, "no cover yet")
|
||||||
|
|
||||||
|
img := pngImage(t)
|
||||||
|
rec := e.put(path, "image/png", img)
|
||||||
|
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
rec = e.do(http.MethodGet, path)
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
assert.Equal(t, "image/png", rec.Header().Get("Content-Type"))
|
||||||
|
assert.Equal(t, img, rec.Body.Bytes())
|
||||||
|
assert.NotEmpty(t, rec.Header().Get("Last-Modified"))
|
||||||
|
|
||||||
|
var soundtrack backend.Soundtrack
|
||||||
|
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()).Body.Bytes(), &soundtrack))
|
||||||
|
assert.NotNil(t, soundtrack.CoverModified)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, e.put(path, "image/png", []byte("not an image")).Code)
|
||||||
|
big := append(pngImage(t), make([]byte, backend.MaxCoverBytes)...)
|
||||||
|
assert.Equal(t, http.StatusRequestEntityTooLarge, e.put(path, "image/png", big).Code)
|
||||||
|
assert.Equal(t, http.StatusNotFound,
|
||||||
|
e.put("/api/v1/soundtracks/00000000-0000-0000-0000-000000000000/cover", "image/png", img).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverPickFromSource(t *testing.T) {
|
||||||
|
e := newTestEnv(t, withCovers(t))
|
||||||
|
song := e.addSong("Game A", "Song", "x")
|
||||||
|
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||||
|
|
||||||
|
rec := e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"42"}`)
|
||||||
|
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
|
||||||
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"7"}`).Code)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, e.doBody(http.MethodPut, path+"/source", `{"source":"steamgriddb","id":"7"}`).Code,
|
||||||
|
"SteamGridDB needs an API key")
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path+"/source", `{"source":"igdb","id":"7"}`).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverDelete(t *testing.T) {
|
||||||
|
e := newTestEnv(t, withCovers(t))
|
||||||
|
song := e.addSong("Game A", "Song", "x")
|
||||||
|
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||||
|
require.Equal(t, http.StatusNoContent, e.put(path, "image/png", pngImage(t)).Code)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, path).Code)
|
||||||
|
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoversDisabled(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
song := e.addSong("Game A", "Song", "x")
|
||||||
|
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code)
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, e.put(path, "image/png", pngImage(t)).Code)
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, e.do(http.MethodDelete, path).Code)
|
||||||
|
rec := e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"42"}`)
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||||
|
body, _ := io.ReadAll(rec.Body)
|
||||||
|
assert.Contains(t, string(body), "not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverWritesRequireToken(t *testing.T) {
|
||||||
|
e := newTestEnv(t, withAuth("secret"), withCovers(t))
|
||||||
|
song := e.addSong("Game A", "Song", "x")
|
||||||
|
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, e.put(path, "image/png", pngImage(t)).Code)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodDelete, path).Code)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, path).Code)
|
||||||
|
}
|
||||||
@@ -334,6 +334,8 @@ func (s statsStore) GetAllSoundtracksWithSongs(context.Context) ([]repository.Ge
|
|||||||
return []repository.GetAllSoundtracksWithSongsRow{{
|
return []repository.GetAllSoundtracksWithSongsRow{{
|
||||||
SoundtrackUuid: statsSoundtrackUUID,
|
SoundtrackUuid: statsSoundtrackUUID,
|
||||||
SoundtrackName: "Game A",
|
SoundtrackName: "Game A",
|
||||||
|
AlternativeNames: []string{"GA"},
|
||||||
|
CoverModified: pgtype.Timestamptz{Time: time.Unix(1730000000, 0), Valid: true},
|
||||||
SoundtrackPlayed: 4,
|
SoundtrackPlayed: 4,
|
||||||
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
|
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
|
||||||
}, {
|
}, {
|
||||||
@@ -375,6 +377,11 @@ func TestStatisticsEndpoints(t *testing.T) {
|
|||||||
assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed)
|
assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed)
|
||||||
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
|
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
|
||||||
assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list")
|
assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list")
|
||||||
|
assert.Equal(t, []string{"GA"}, soundtracks[0].AlternativeNames)
|
||||||
|
require.NotNil(t, soundtracks[0].CoverModified)
|
||||||
|
assert.Equal(t, int64(1730000000), *soundtracks[0].CoverModified)
|
||||||
|
assert.Equal(t, []string{}, soundtracks[1].AlternativeNames, "no names is an empty list")
|
||||||
|
assert.Nil(t, soundtracks[1].CoverModified)
|
||||||
|
|
||||||
var summary backend.StatisticsSummary
|
var summary backend.StatisticsSummary
|
||||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary))
|
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
@@ -19,6 +20,20 @@ func (in *uuidInput) id() uuid.UUID {
|
|||||||
return uuid.MustParse(in.UUID)
|
return uuid.MustParse(in.UUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AlternativeNames is the body of PUT /soundtracks/{uuid}/alternative-names.
|
||||||
|
type AlternativeNames struct {
|
||||||
|
Names []string `json:"names" maxItems:"50" doc:"The new list of alternative names. Replaces the old list; send [] to remove them all."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type alternativeNamesInput struct {
|
||||||
|
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
|
||||||
|
Body AlternativeNames
|
||||||
|
}
|
||||||
|
|
||||||
|
func (in *alternativeNamesInput) id() uuid.UUID {
|
||||||
|
return uuid.MustParse(in.UUID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) registerLibrary(a huma.API) {
|
func (s *Server) registerLibrary(a huma.API) {
|
||||||
l := s.library
|
l := s.library
|
||||||
op := func(id, path, summary, description string, errs ...int) huma.Operation {
|
op := func(id, path, summary, description string, errs ...int) huma.Operation {
|
||||||
@@ -49,6 +64,27 @@ func (s *Server) registerLibrary(a huma.API) {
|
|||||||
return ok(l.Song(ctx, in.id()))
|
return ok(l.Song(ctx, in.id()))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
names := op("set-alternative-names", "/soundtracks/{uuid}/alternative-names", "Set alternative names",
|
||||||
|
"Replaces the soundtrack's alternative names, which search and the cover search also use. "+
|
||||||
|
"Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.",
|
||||||
|
http.StatusNotFound)
|
||||||
|
names.Method = http.MethodPut
|
||||||
|
register(s, a, route{op: names},
|
||||||
|
func(ctx context.Context, in *alternativeNamesInput) (*jsonBody[backend.Soundtrack], error) {
|
||||||
|
for _, name := range in.Body.Names {
|
||||||
|
if len(name) > backend.MaxNameLength {
|
||||||
|
return nil, huma.Error422UnprocessableEntity(
|
||||||
|
fmt.Sprintf("names can be at most %d bytes long", backend.MaxNameLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
soundtrack, err := l.SetAlternativeNames(ctx, in.id(), in.Body.Names)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apiError(err)
|
||||||
|
}
|
||||||
|
s.soundtracksChanged(ctx)
|
||||||
|
return ok(soundtrack, nil)
|
||||||
|
})
|
||||||
|
|
||||||
file := op("get-song-file", "/songs/{uuid}/file", "Stream song",
|
file := op("get-song-file", "/songs/{uuid}/file", "Stream song",
|
||||||
"Returns the audio file of a song.", http.StatusNotFound)
|
"Returns the audio file of a song.", http.StatusNotFound)
|
||||||
file.Responses = fileResponses("The song (MP3)", audioMPEG)
|
file.Responses = fileResponses("The song (MP3)", audioMPEG)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
@@ -84,3 +86,47 @@ func TestQueueResponsesIncludeUUIDs(t *testing.T) {
|
|||||||
require.Len(t, list, 1)
|
require.Len(t, list, 1)
|
||||||
assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"])
|
assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetAlternativeNames(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
song := e.addSong("Final Fantasy VII", "Prelude", "x")
|
||||||
|
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names"
|
||||||
|
|
||||||
|
rec := e.doBody(http.MethodPut, path, `{"names":[" FF7 ","ff7","Final Fantasy VII"]}`)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
var soundtrack backend.Soundtrack
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &soundtrack))
|
||||||
|
assert.Equal(t, []string{"FF7"}, soundtrack.AlternativeNames)
|
||||||
|
|
||||||
|
rec = e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String())
|
||||||
|
assert.Contains(t, rec.Body.String(), `"alternative_names":["FF7"]`)
|
||||||
|
|
||||||
|
// The search page finds it by the new name right away.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader("search_term=ff7"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
e.handler.ServeHTTP(rec, req)
|
||||||
|
assert.Contains(t, rec.Body.String(), "Final Fantasy VII")
|
||||||
|
assert.Contains(t, rec.Body.String(), "Also known as FF7")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAlternativeNamesValidation(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
song := e.addSong("Game A", "Song", "x")
|
||||||
|
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names"
|
||||||
|
|
||||||
|
long := strings.Repeat("a", backend.MaxNameLength+1)
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{"names":["`+long+`"]}`).Code)
|
||||||
|
many := `{"names":["` + strings.Repeat(`x","`, 50) + `x"]}`
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, many).Code, "at most 50 names")
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{}`).Code, "names is required")
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity,
|
||||||
|
e.doBody(http.MethodPut, "/api/v1/soundtracks/not-a-uuid/alternative-names", `{"names":[]}`).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAlternativeNamesRequiresToken(t *testing.T) {
|
||||||
|
e := newTestEnv(t, withAuth("secret"))
|
||||||
|
song := e.addSong("Game A", "Song", "x")
|
||||||
|
rec := e.doBody(http.MethodPut, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()+"/alternative-names", `{"names":["A"]}`)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
|
|||||||
}
|
}
|
||||||
slices.Sort(operations)
|
slices.Sort(operations)
|
||||||
assert.Equal(t, []string{
|
assert.Equal(t, []string{
|
||||||
|
"DELETE /api/v1/soundtracks/{uuid}/cover",
|
||||||
"DELETE /api/v1/token",
|
"DELETE /api/v1/token",
|
||||||
"GET /api/v1/character",
|
"GET /api/v1/character",
|
||||||
"GET /api/v1/character/details",
|
"GET /api/v1/character/details",
|
||||||
@@ -102,6 +103,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
|
|||||||
"GET /api/v1/songs/{uuid}/file",
|
"GET /api/v1/songs/{uuid}/file",
|
||||||
"GET /api/v1/soundtracks",
|
"GET /api/v1/soundtracks",
|
||||||
"GET /api/v1/soundtracks/{uuid}",
|
"GET /api/v1/soundtracks/{uuid}",
|
||||||
|
"GET /api/v1/soundtracks/{uuid}/cover",
|
||||||
"GET /api/v1/statistics/songs/least-played",
|
"GET /api/v1/statistics/songs/least-played",
|
||||||
"GET /api/v1/statistics/songs/most-played",
|
"GET /api/v1/statistics/songs/most-played",
|
||||||
"GET /api/v1/statistics/soundtracks",
|
"GET /api/v1/statistics/soundtracks",
|
||||||
@@ -119,6 +121,9 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
|
|||||||
"POST /api/v1/token",
|
"POST /api/v1/token",
|
||||||
"POST /api/v1/token/cleanup",
|
"POST /api/v1/token/cleanup",
|
||||||
"PUT /api/v1/music/played",
|
"PUT /api/v1/music/played",
|
||||||
|
"PUT /api/v1/soundtracks/{uuid}/alternative-names",
|
||||||
|
"PUT /api/v1/soundtracks/{uuid}/cover",
|
||||||
|
"PUT /api/v1/soundtracks/{uuid}/cover/source",
|
||||||
}, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented")
|
}, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented")
|
||||||
|
|
||||||
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
|
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
|
||||||
@@ -169,7 +174,7 @@ func (v *specValidator) check(method, path, body string, wantStatus int, headers
|
|||||||
|
|
||||||
func TestResponsesMatchOpenAPI(t *testing.T) {
|
func TestResponsesMatchOpenAPI(t *testing.T) {
|
||||||
gitea := newGitea(t, http.StatusOK)
|
gitea := newGitea(t, http.StatusOK)
|
||||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea })
|
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea }, withCovers(t))
|
||||||
v := newSpecValidator(t, e)
|
v := newSpecValidator(t, e)
|
||||||
song := e.addSong("Game A", "Song", "0123456789")
|
song := e.addSong("Game A", "Song", "0123456789")
|
||||||
songURL := "/api/v1/songs/" + song.Uuid.String()
|
songURL := "/api/v1/songs/" + song.Uuid.String()
|
||||||
@@ -202,6 +207,20 @@ func TestResponsesMatchOpenAPI(t *testing.T) {
|
|||||||
// Library.
|
// Library.
|
||||||
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
|
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
|
||||||
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK)
|
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK)
|
||||||
|
v.check(http.MethodPut, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()+"/alternative-names",
|
||||||
|
`{"names":["Game Alpha"]}`, http.StatusOK)
|
||||||
|
v.check(http.MethodPut, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000/alternative-names",
|
||||||
|
`{"names":[]}`, http.StatusNotFound)
|
||||||
|
|
||||||
|
// Covers.
|
||||||
|
coverURL := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||||
|
v.check(http.MethodGet, coverURL, "", http.StatusNotFound)
|
||||||
|
v.check(http.MethodPut, coverURL, string(pngImage(t)), http.StatusNoContent, "Content-Type", "image/png")
|
||||||
|
v.check(http.MethodPut, coverURL, "plain text", http.StatusBadRequest, "Content-Type", "image/png")
|
||||||
|
v.check(http.MethodGet, coverURL, "", http.StatusOK)
|
||||||
|
v.check(http.MethodPut, coverURL+"/source", `{"source":"anilist","id":"42"}`, http.StatusNoContent)
|
||||||
|
v.check(http.MethodPut, coverURL+"/source", `{"source":"anilist","id":"7"}`, http.StatusNotFound)
|
||||||
|
v.check(http.MethodDelete, coverURL, "", http.StatusNoContent)
|
||||||
v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
|
v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
|
||||||
v.check(http.MethodGet, songURL, "", http.StatusOK)
|
v.check(http.MethodGet, songURL, "", http.StatusOK)
|
||||||
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
|
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func (s *Server) routes() (*echo.Echo, huma.API) {
|
|||||||
s.registerSync(a)
|
s.registerSync(a)
|
||||||
s.registerMusic(a)
|
s.registerMusic(a)
|
||||||
s.registerLibrary(a)
|
s.registerLibrary(a)
|
||||||
|
s.registerCovers(a)
|
||||||
s.registerStatistics(a)
|
s.registerStatistics(a)
|
||||||
|
|
||||||
// Older links to the spec and docs.
|
// Older links to the spec and docs.
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ type Server struct {
|
|||||||
releases *backend.ReleaseService
|
releases *backend.ReleaseService
|
||||||
stats *backend.StatisticsService
|
stats *backend.StatisticsService
|
||||||
search *backend.SearchCache
|
search *backend.SearchCache
|
||||||
|
// covers is nil when COVERS_PATH is not set.
|
||||||
|
covers *backend.CoverService
|
||||||
|
// stopCovers ends the background cover search; coversDone is closed when it has ended.
|
||||||
|
stopCovers context.CancelFunc
|
||||||
|
coversDone chan struct{}
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
// metrics serves /metrics on its own port; nil when metrics are off.
|
// metrics serves /metrics on its own port; nil when metrics are off.
|
||||||
metrics *metrics.Server
|
metrics *metrics.Server
|
||||||
@@ -57,9 +62,19 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
|
|||||||
releases: backend.NewReleaseService(cfg.ReleasesURL),
|
releases: backend.NewReleaseService(cfg.ReleasesURL),
|
||||||
stats: backend.NewStatisticsService(q),
|
stats: backend.NewStatisticsService(q),
|
||||||
}
|
}
|
||||||
s.search = backend.NewSearchCache(s.music.SoundtrackNames)
|
s.search = backend.NewSearchCache(s.music.SearchSoundtracks)
|
||||||
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
|
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
|
||||||
|
|
||||||
|
if cfg.CoversPath != "" {
|
||||||
|
s.covers, err = backend.NewCoverService(q, cfg.CoversPath, backend.CoverOptions{
|
||||||
|
SteamGridDBKey: cfg.SteamGridDBAPIKey,
|
||||||
|
SteamGridDBURL: cfg.SteamGridDBURL,
|
||||||
|
AniListURL: cfg.AniListURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("covers path: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if cfg.MetricsPort > 0 {
|
if cfg.MetricsPort > 0 {
|
||||||
s.metrics = metrics.NewServer(cfg.MetricsPort)
|
s.metrics = metrics.NewServer(cfg.MetricsPort)
|
||||||
}
|
}
|
||||||
@@ -73,6 +88,36 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startCoverSearch runs the background cover search until Shutdown. It does nothing when
|
||||||
|
// covers are off or there is no SteamGridDB API key.
|
||||||
|
func (s *Server) startCoverSearch() {
|
||||||
|
if s.covers == nil || !s.covers.SearchesAutomatically() {
|
||||||
|
logging.GetLogger().Info("Automatic cover search is off",
|
||||||
|
zap.Bool("covers_path_set", s.covers != nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s.stopCovers, s.coversDone = cancel, make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(s.coversDone)
|
||||||
|
s.covers.Run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// soundtracksChanged reloads the cached soundtrack data after soundtracks were edited, and
|
||||||
|
// lets the cover search try the new names.
|
||||||
|
func (s *Server) soundtracksChanged(ctx context.Context) {
|
||||||
|
if err := s.music.ReloadSoundtracks(ctx); err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to reload soundtracks", zap.Error(err))
|
||||||
|
}
|
||||||
|
if err := s.search.Reload(ctx); err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to reload search cache", zap.Error(err))
|
||||||
|
}
|
||||||
|
if s.covers != nil {
|
||||||
|
s.covers.Trigger()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// afterSync refreshes cached data once a sync has finished.
|
// afterSync refreshes cached data once a sync has finished.
|
||||||
func (s *Server) afterSync(ctx context.Context) {
|
func (s *Server) afterSync(ctx context.Context) {
|
||||||
if err := s.music.EndAllQueues(ctx); err != nil {
|
if err := s.music.EndAllQueues(ctx); err != nil {
|
||||||
@@ -84,6 +129,9 @@ func (s *Server) afterSync(ctx context.Context) {
|
|||||||
if err := s.search.Reload(ctx); err != nil {
|
if err := s.search.Reload(ctx); err != nil {
|
||||||
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
|
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
if s.covers != nil {
|
||||||
|
s.covers.Trigger()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
|
// NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
|
||||||
@@ -133,6 +181,7 @@ func (s *Server) ListenAndServe() error {
|
|||||||
logging.GetLogger().Error("Failed to start the metrics server", zap.Error(err))
|
logging.GetLogger().Error("Failed to start the metrics server", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.startCoverSearch()
|
||||||
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
|
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
|
||||||
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||||
return err
|
return err
|
||||||
@@ -146,6 +195,10 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
|||||||
if s.metrics != nil {
|
if s.metrics != nil {
|
||||||
err = errors.Join(err, s.metrics.Shutdown(ctx))
|
err = errors.Join(err, s.metrics.Shutdown(ctx))
|
||||||
}
|
}
|
||||||
|
if s.stopCovers != nil {
|
||||||
|
s.stopCovers()
|
||||||
|
<-s.coversDone
|
||||||
|
}
|
||||||
s.sync.Wait()
|
s.sync.Wait()
|
||||||
s.characters.Close()
|
s.characters.Close()
|
||||||
s.db.Close()
|
s.db.Close()
|
||||||
|
|||||||
@@ -193,6 +193,93 @@ func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository
|
|||||||
return repository.Soundtrack{}, pgx.ErrNoRows
|
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 {
|
func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error {
|
||||||
defer s.lock()()
|
defer s.lock()()
|
||||||
if s.Err != nil {
|
if s.Err != nil {
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ sql:
|
|||||||
pointer: true
|
pointer: true
|
||||||
import: "time"
|
import: "time"
|
||||||
type: "Time"
|
type: "Time"
|
||||||
|
- db_type: "text"
|
||||||
|
nullable: true
|
||||||
|
go_type:
|
||||||
|
pointer: true
|
||||||
|
type: "string"
|
||||||
- db_type: "pg_catalog.varchar"
|
- db_type: "pg_catalog.varchar"
|
||||||
nullable: true
|
nullable: true
|
||||||
go_type:
|
go_type:
|
||||||
|
|||||||
Reference in new issue
Block a user