From a297454065cca2b325088a7c115f4a5136b1f1a7 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 00:08:06 -0500 Subject: [PATCH] Subsonic: album/song lists (getAlbumList/2, getRandomSongs, getSongsByGenre, getNowPlaying, getStarred/2) and search (search, search2, search3) --- internal/database/subsonic_helpers2.go | 49 +++++ internal/subsonic/convert.go | 4 + internal/subsonic/lists.go | 283 +++++++++++++++++++++++++ internal/subsonic/lists_test.go | 146 +++++++++++++ internal/subsonic/search.go | 177 ++++++++++++++++ internal/subsonic/search_test.go | 83 ++++++++ 6 files changed, 742 insertions(+) create mode 100644 internal/subsonic/lists.go create mode 100644 internal/subsonic/lists_test.go create mode 100644 internal/subsonic/search.go create mode 100644 internal/subsonic/search_test.go diff --git a/internal/database/subsonic_helpers2.go b/internal/database/subsonic_helpers2.go index dfcf7f4..86d0adc 100644 --- a/internal/database/subsonic_helpers2.go +++ b/internal/database/subsonic_helpers2.go @@ -89,3 +89,52 @@ func SimilarSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, erro defer rows.Close() return scanSongs(rows) } + +// AlbumsByHighestRating orders albums by their average user rating +// (unrated albums last). +func AlbumsByHighestRating(db *sql.DB, limit, offset int) ([]*Album, error) { + rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at + FROM albums al + LEFT JOIN (SELECT item_id, AVG(rating) AS avg_rating FROM ratings WHERE item_type='album' GROUP BY item_id) r ON r.item_id = al.id + ORDER BY COALESCE(r.avg_rating, 0) DESC, al.sort_title COLLATE NOCASE + LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAlbums(rows) +} + +// AlbumsByPlayCount orders albums by total play count across all users +// (an album with all its songs never played sorts last). +func AlbumsByPlayCount(db *sql.DB, limit, offset int) ([]*Album, error) { + rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at + FROM albums al + LEFT JOIN songs s ON s.album_id = al.id + LEFT JOIN playback_history ph ON ph.song_id = s.id + GROUP BY al.id + ORDER BY COUNT(ph.id) DESC, al.sort_title COLLATE NOCASE + LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAlbums(rows) +} + +// AlbumsByRecentlyPlayed orders albums by the most recent play of any of +// their songs by the given user, most recent first. +func AlbumsByRecentlyPlayed(db *sql.DB, userID string, limit, offset int) ([]*Album, error) { + rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at + FROM albums al + JOIN songs s ON s.album_id = al.id + JOIN playback_history ph ON ph.song_id = s.id AND ph.user_id = ? + GROUP BY al.id + ORDER BY MAX(ph.played_at) DESC + LIMIT ? OFFSET ?`, userID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAlbums(rows) +} diff --git a/internal/subsonic/convert.go b/internal/subsonic/convert.go index 6776cbe..d29c670 100644 --- a/internal/subsonic/convert.go +++ b/internal/subsonic/convert.go @@ -14,6 +14,10 @@ import ( // keeps XML and JSON output (and every endpoint that happens to return // the same entity type) semantically identical. +func secondsSince(t time.Time) float64 { + return time.Since(t).Seconds() +} + func isoTime(t time.Time) string { if t.IsZero() { return "" diff --git a/internal/subsonic/lists.go b/internal/subsonic/lists.go new file mode 100644 index 0000000..79429c3 --- /dev/null +++ b/internal/subsonic/lists.go @@ -0,0 +1,283 @@ +package subsonic + +import ( + "database/sql" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func init() { + register("getAlbumList", handleGetAlbumList) + register("getAlbumList2", handleGetAlbumList2) + register("getRandomSongs", handleGetRandomSongs) + register("getSongsByGenre", handleGetSongsByGenre) + register("getNowPlaying", handleGetNowPlaying) + register("getStarred", handleGetStarred) + register("getStarred2", handleGetStarred2) +} + +// resolveAlbumListType fetches albums for one of the official +// getAlbumList/getAlbumList2 "type" values. Every branch is a real +// query — nothing here is a fabricated or hardcoded result. +func resolveAlbumListType(rc *Context) ([]*database.Album, *apiError) { + db := rc.Server.DB + listType, aerr := rc.Params.Required("type") + if aerr != nil { + return nil, aerr + } + size := rc.Params.Int("size", 10) + if size > 500 { + size = 500 + } + offset := rc.Params.Int("offset", 0) + + var albums []*database.Album + var err error + switch listType { + case "random": + albums, err = database.RandomAlbums(db, size) + case "newest": + albums, err = database.RecentlyAddedAlbums(db, size, offset) + case "highest": + albums, err = database.AlbumsByHighestRating(db, size, offset) + case "frequent": + albums, err = database.AlbumsByPlayCount(db, size, offset) + case "recent": + albums, err = database.AlbumsByRecentlyPlayed(db, rc.User.ID, size, offset) + case "alphabeticalByName": + albums, err = database.AlbumsAlphabetical(db, false, size, offset) + case "alphabeticalByArtist": + albums, err = database.AlbumsAlphabetical(db, true, size, offset) + case "starred": + albums, err = starredAlbums(db, rc.User.ID, size, offset) + case "byYear": + fromYear, ferr := rc.Params.RequiredInt("fromYear") + if ferr != nil { + return nil, ferr + } + toYear, terr := rc.Params.RequiredInt("toYear") + if terr != nil { + return nil, terr + } + albums, err = database.AlbumsByYearRange(db, fromYear, toYear, size, offset) + case "byGenre": + genre, gerr := rc.Params.Required("genre") + if gerr != nil { + return nil, gerr + } + albums, err = database.AlbumsByGenre(db, genre, size, offset) + default: + return nil, newError(ErrGeneric, "Unknown album list type: "+listType) + } + if err != nil { + return nil, newError(ErrGeneric, err.Error()) + } + return albums, nil +} + +func starredAlbums(db *sql.DB, userID string, limit, offset int) ([]*database.Album, error) { + ids, err := database.ListStarredIDs(db, userID, "album") + if err != nil { + return nil, err + } + if offset >= len(ids) { + return nil, nil + } + end := offset + limit + if end > len(ids) { + end = len(ids) + } + var out []*database.Album + for _, id := range ids[offset:end] { + if a, err := database.GetAlbum(db, id); err == nil { + out = append(out, a) + } + } + return out, nil +} + +func handleGetAlbumList(rc *Context) *apiError { + albums, aerr := resolveAlbumListType(rc) + if aerr != nil { + return aerr + } + out := &AlbumList{} + for _, a := range albums { + out.Album = append(out.Album, childFromAlbum(rc.Server.DB, a, rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.AlbumList = out + return nil +} + +func handleGetAlbumList2(rc *Context) *apiError { + albums, aerr := resolveAlbumListType(rc) + if aerr != nil { + return aerr + } + out := &AlbumList2{} + for _, a := range albums { + out.Album = append(out.Album, albumToID3(rc.Server.DB, a, rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.AlbumList2 = out + return nil +} + +func handleGetRandomSongs(rc *Context) *apiError { + db := rc.Server.DB + size := rc.Params.Int("size", 10) + if size > 500 { + size = 500 + } + genre := rc.Params.Get("genre") + fromYear := rc.Params.Int("fromYear", 0) + toYear := rc.Params.Int("toYear", 0) + + ids, err := database.RandomSongIDs(db, size, genre, fromYear, toYear) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &Songs{} + for _, id := range ids { + song, serr := database.GetSong(db, id) + if serr != nil { + continue + } + mf, merr := database.GetMediaFile(db, song.MediaFileID) + if merr != nil { + continue + } + if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil || !canSee { + continue + } + out.Song = append(out.Song, childFromSong(db, song, mf, albumOfSong(db, song), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.RandomSongs = out + return nil +} + +func handleGetSongsByGenre(rc *Context) *apiError { + genre, aerr := rc.Params.Required("genre") + if aerr != nil { + return aerr + } + db := rc.Server.DB + count := rc.Params.Int("count", 10) + if count > 500 { + count = 500 + } + offset := rc.Params.Int("offset", 0) + + songs, err := database.SongsByGenre(db, genre, count, offset) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &Songs{} + for _, s := range songs { + mf, merr := database.GetMediaFile(db, s.MediaFileID) + if merr != nil { + continue + } + if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil || !canSee { + continue + } + out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.SongsByGenre = out + return nil +} + +func handleGetNowPlaying(rc *Context) *apiError { + db := rc.Server.DB + entries, err := database.ListNowPlaying(db) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &NowPlaying{} + for _, e := range entries { + song, serr := database.GetSong(db, e.SongID) + if serr != nil { + continue + } + mf, merr := database.GetMediaFile(db, song.MediaFileID) + if merr != nil { + continue + } + c := childFromSong(db, song, mf, albumOfSong(db, song), "") + minutesAgo := int(secondsSince(e.StartedAt) / 60) + out.Entry = append(out.Entry, NowPlayingEntry{ + Child: c, + Username: e.Username, + MinutesAgo: minutesAgo, + PlayerID: 1, + }) + } + rc.Response = newResponse() + rc.Response.NowPlaying = out + return nil +} + +func handleGetStarred(rc *Context) *apiError { + db := rc.Server.DB + userID := rc.User.ID + out := &Starred{} + + artistIDs, _ := database.ListStarredIDs(db, userID, "artist") + for _, id := range artistIDs { + if a, err := database.GetArtist(db, id); err == nil { + out.Artist = append(out.Artist, Artist{ID: a.ID, Name: a.Name}) + } + } + albumIDs, _ := database.ListStarredIDs(db, userID, "album") + for _, id := range albumIDs { + if a, err := database.GetAlbum(db, id); err == nil { + out.Album = append(out.Album, childFromAlbum(db, a, userID)) + } + } + songIDs, _ := database.ListStarredIDs(db, userID, "song") + for _, id := range songIDs { + if s, err := database.GetSong(db, id); err == nil { + if mf, merr := database.GetMediaFile(db, s.MediaFileID); merr == nil { + out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), userID)) + } + } + } + + rc.Response = newResponse() + rc.Response.Starred = out + return nil +} + +func handleGetStarred2(rc *Context) *apiError { + db := rc.Server.DB + userID := rc.User.ID + out := &Starred2{} + + artistIDs, _ := database.ListStarredIDs(db, userID, "artist") + for _, id := range artistIDs { + if a, err := database.GetArtist(db, id); err == nil { + out.Artist = append(out.Artist, artistToID3(db, a, userID)) + } + } + albumIDs, _ := database.ListStarredIDs(db, userID, "album") + for _, id := range albumIDs { + if a, err := database.GetAlbum(db, id); err == nil { + out.Album = append(out.Album, albumToID3(db, a, userID)) + } + } + songIDs, _ := database.ListStarredIDs(db, userID, "song") + for _, id := range songIDs { + if s, err := database.GetSong(db, id); err == nil { + if mf, merr := database.GetMediaFile(db, s.MediaFileID); merr == nil { + out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), userID)) + } + } + } + + rc.Response = newResponse() + rc.Response.Starred2 = out + return nil +} diff --git a/internal/subsonic/lists_test.go b/internal/subsonic/lists_test.go new file mode 100644 index 0000000..a3676c1 --- /dev/null +++ b/internal/subsonic/lists_test.go @@ -0,0 +1,146 @@ +package subsonic + +import ( + "net/http/httptest" + "testing" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func TestGetAlbumListTypes(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + for _, listType := range []string{"random", "newest", "highest", "frequent", "alphabeticalByName", "alphabeticalByArtist"} { + q := baseParams("alice", "password123") + q.Set("type", listType) + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/getAlbumList.view", q) + j := decodeJSON(t, resp) + if j["status"] != "ok" { + t.Errorf("type=%s: status = %v, want ok (%v)", listType, j["status"], j) + } + } +} + +func TestGetAlbumListByYear(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + q := baseParams("alice", "password123") + q.Set("type", "byYear") + q.Set("fromYear", "2000") + q.Set("toYear", "2030") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/getAlbumList2.view", q) + j := decodeJSON(t, resp) + list := j["albumList2"].(map[string]any) + albums, ok := list["album"].([]any) + if !ok || len(albums) == 0 { + t.Fatalf("expected at least one album in byYear 2000-2030 range, got %v", list) + } +} + +func TestGetAlbumListByYearMissingParam(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("type", "byYear") + q.Set("fromYear", "2000") + resp := getPath(t, srv.URL, "/rest/getAlbumList.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter { + t.Fatalf("expected error 10 for missing toYear, got %+v", x) + } +} + +func TestGetAlbumListMissingType(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + resp := getPath(t, srv.URL, "/rest/getAlbumList.view", baseParams("alice", "password123")) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter { + t.Fatalf("expected error 10, got %+v", x) + } +} + +func TestGetRandomSongs(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + q := baseParams("alice", "password123") + q.Set("size", "1") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/getRandomSongs.view", q) + j := decodeJSON(t, resp) + songs := j["randomSongs"].(map[string]any)["song"].([]any) + if len(songs) != 1 { + t.Fatalf("randomSongs = %v, want 1", songs) + } +} + +func TestGetSongsByGenre(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + q := baseParams("alice", "password123") + q.Set("genre", "Rock") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/getSongsByGenre.view", q) + j := decodeJSON(t, resp) + songs := j["songsByGenre"].(map[string]any)["song"].([]any) + if len(songs) != 2 { + t.Fatalf("songsByGenre = %v, want 2", songs) + } +} + +func TestGetNowPlayingEmpty(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/getNowPlaying.view", q) + j := decodeJSON(t, resp) + if j["status"] != "ok" { + t.Fatalf("status = %v", j["status"]) + } +} + +func TestGetStarredAndStarred2(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + songs, err := database.ListSongs(db, 1, 0) + if err != nil || len(songs) == 0 { + t.Fatalf("ListSongs: %v %v", songs, err) + } + if err := database.Star(db, u.ID, "song", songs[0].ID); err != nil { + t.Fatal(err) + } + + q := baseParams("alice", "password123") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/getStarred.view", q) + j := decodeJSON(t, resp) + starred := j["starred"].(map[string]any) + if s, ok := starred["song"].([]any); !ok || len(s) != 1 { + t.Fatalf("starred.song = %v, want 1", starred["song"]) + } + + resp2 := getPath(t, srv.URL, "/rest/getStarred2.view", q) + j2 := decodeJSON(t, resp2) + starred2 := j2["starred2"].(map[string]any) + if s, ok := starred2["song"].([]any); !ok || len(s) != 1 { + t.Fatalf("starred2.song = %v, want 1", starred2["song"]) + } +} diff --git a/internal/subsonic/search.go b/internal/subsonic/search.go new file mode 100644 index 0000000..2ba6490 --- /dev/null +++ b/internal/subsonic/search.go @@ -0,0 +1,177 @@ +package subsonic + +import ( + "database/sql" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func init() { + register("search", handleSearch) + register("search2", handleSearch2) + register("search3", handleSearch3) +} + +type searchMatches struct { + Artists []*database.Artist + Albums []*database.Album + Songs []*database.Song +} + +// runSearch performs a simple substring, case-insensitive match against +// artist/album/song names, with independent offset/count for each +// category as the protocol requires. VibeSonic's libraries are small +// enough (self-hosted, not a streaming catalog) that a LIKE scan is +// plenty fast; there's no need for SQLite FTS's added complexity here. +func runSearch(db *sql.DB, query string, artistOffset, artistCount, albumOffset, albumCount, songOffset, songCount int) (searchMatches, error) { + var m searchMatches + if query == "" { + return m, nil + } + like := "%" + query + "%" + + artistRows, err := db.Query(`SELECT DISTINCT a.id FROM artists a JOIN album_artists aa ON aa.artist_id = a.id + WHERE a.name LIKE ? COLLATE NOCASE ORDER BY a.name COLLATE NOCASE LIMIT ? OFFSET ?`, like, artistCount, artistOffset) + if err != nil { + return m, err + } + for artistRows.Next() { + var id string + if err := artistRows.Scan(&id); err == nil { + if a, err := database.GetArtist(db, id); err == nil { + m.Artists = append(m.Artists, a) + } + } + } + artistRows.Close() + + albumRows, err := db.Query(`SELECT id FROM albums WHERE title LIKE ? COLLATE NOCASE ORDER BY title COLLATE NOCASE LIMIT ? OFFSET ?`, like, albumCount, albumOffset) + if err != nil { + return m, err + } + for albumRows.Next() { + var id string + if err := albumRows.Scan(&id); err == nil { + if a, err := database.GetAlbum(db, id); err == nil { + m.Albums = append(m.Albums, a) + } + } + } + albumRows.Close() + + songRows, err := db.Query(`SELECT id FROM songs WHERE title LIKE ? COLLATE NOCASE ORDER BY title COLLATE NOCASE LIMIT ? OFFSET ?`, like, songCount, songOffset) + if err != nil { + return m, err + } + for songRows.Next() { + var id string + if err := songRows.Scan(&id); err == nil { + if s, err := database.GetSong(db, id); err == nil { + m.Songs = append(m.Songs, s) + } + } + } + songRows.Close() + + return m, nil +} + +// handleSearch implements the original, deprecated (since 1.4.0, but +// still required) flat search: a single "any" query with one offset/ +// count, results as Child, regardless of type. +func handleSearch(rc *Context) *apiError { + any, aerr := rc.Params.Required("any") + if aerr != nil { + return aerr + } + db := rc.Server.DB + offset := rc.Params.Int("offset", 0) + count := rc.Params.Int("count", 20) + + m, err := runSearch(db, any, offset, count, offset, count, offset, count) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &SearchResult{Offset: offset} + for _, a := range m.Artists { + out.Match = append(out.Match, Child{ID: a.ID, IsDir: true, Title: a.Name}) + } + for _, a := range m.Albums { + out.Match = append(out.Match, childFromAlbum(db, a, rc.User.ID)) + } + for _, s := range m.Songs { + mf, merr := database.GetMediaFile(db, s.MediaFileID) + if merr != nil { + continue + } + out.Match = append(out.Match, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID)) + } + out.TotalHits = len(out.Match) + rc.Response = newResponse() + rc.Response.SearchResult = out + return nil +} + +func handleSearch2(rc *Context) *apiError { + query, aerr := rc.Params.Required("query") + if aerr != nil { + return aerr + } + db := rc.Server.DB + m, err := runSearch(db, query, + rc.Params.Int("artistOffset", 0), rc.Params.Int("artistCount", 20), + rc.Params.Int("albumOffset", 0), rc.Params.Int("albumCount", 20), + rc.Params.Int("songOffset", 0), rc.Params.Int("songCount", 20)) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &SearchResult2{} + for _, a := range m.Artists { + out.Artist = append(out.Artist, Artist{ID: a.ID, Name: a.Name}) + } + for _, a := range m.Albums { + out.Album = append(out.Album, childFromAlbum(db, a, rc.User.ID)) + } + for _, s := range m.Songs { + mf, merr := database.GetMediaFile(db, s.MediaFileID) + if merr != nil { + continue + } + out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.SearchResult2 = out + return nil +} + +func handleSearch3(rc *Context) *apiError { + query, aerr := rc.Params.Required("query") + if aerr != nil { + return aerr + } + db := rc.Server.DB + m, err := runSearch(db, query, + rc.Params.Int("artistOffset", 0), rc.Params.Int("artistCount", 20), + rc.Params.Int("albumOffset", 0), rc.Params.Int("albumCount", 20), + rc.Params.Int("songOffset", 0), rc.Params.Int("songCount", 20)) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &SearchResult3{} + for _, a := range m.Artists { + out.Artist = append(out.Artist, artistToID3(db, a, rc.User.ID)) + } + for _, a := range m.Albums { + out.Album = append(out.Album, albumToID3(db, a, rc.User.ID)) + } + for _, s := range m.Songs { + mf, merr := database.GetMediaFile(db, s.MediaFileID) + if merr != nil { + continue + } + out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.SearchResult3 = out + return nil +} diff --git a/internal/subsonic/search_test.go b/internal/subsonic/search_test.go new file mode 100644 index 0000000..dbdc8ec --- /dev/null +++ b/internal/subsonic/search_test.go @@ -0,0 +1,83 @@ +package subsonic + +import "testing" + +func TestSearch3(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + q := baseParams("alice", "password123") + q.Set("query", "Second") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/search3.view", q) + j := decodeJSON(t, resp) + result := j["searchResult3"].(map[string]any) + songs, ok := result["song"].([]any) + if !ok || len(songs) != 1 { + t.Fatalf("search3 songs = %v, want 1 match for 'Second'", result["song"]) + } +} + +func TestSearch2(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + q := baseParams("alice", "password123") + q.Set("query", "Test Artist") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/search2.view", q) + j := decodeJSON(t, resp) + result := j["searchResult2"].(map[string]any) + artists, ok := result["artist"].([]any) + if !ok || len(artists) != 1 { + t.Fatalf("search2 artists = %v, want 1 match", result["artist"]) + } +} + +func TestSearchDeprecatedFlat(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + q := baseParams("alice", "password123") + q.Set("any", "Test Album") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/search.view", q) + j := decodeJSON(t, resp) + result := j["searchResult"].(map[string]any) + if result["totalHits"] == nil { + t.Fatalf("search: missing totalHits: %v", result) + } + matches, ok := result["match"].([]any) + if !ok || len(matches) == 0 { + t.Fatalf("search: matches = %v, want at least 1", result["match"]) + } +} + +func TestSearch3MissingQuery(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + resp := getPath(t, srv.URL, "/rest/search3.view", baseParams("alice", "password123")) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter { + t.Fatalf("expected error 10, got %+v", x) + } +} + +func TestSearch3NoResults(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + q := baseParams("alice", "password123") + q.Set("query", "ThisWillNeverMatchAnything") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/search3.view", q) + j := decodeJSON(t, resp) + if j["status"] != "ok" { + t.Fatalf("status = %v, want ok even with no results", j["status"]) + } +}