package subsonic import ( "context" "encoding/xml" "net/http/httptest" "os" "os/exec" "path/filepath" "strconv" "testing" "time" "git.noroutes.net/NoRoutes/vibetunes/internal/database" ) // seedLibrary scans a small real (ffmpeg-generated) library into s so // browsing endpoints have real rows to return. Skips the test if // ffmpeg/ffprobe aren't installed. func seedLibrary(t *testing.T, s *Server) (folder *database.MusicFolder) { t.Helper() if _, err := exec.LookPath("ffmpeg"); err != nil { t.Skip("ffmpeg not installed") } if _, err := exec.LookPath("ffprobe"); err != nil { t.Skip("ffprobe not installed") } dir := t.TempDir() songDir := filepath.Join(dir, "Test Artist", "Test Album") if err := os.MkdirAll(songDir, 0o755); err != nil { t.Fatal(err) } for i, title := range []string{"First Song", "Second Song"} { out := filepath.Join(songDir, title+".mp3") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) cmd := exec.CommandContext(ctx, "ffmpeg", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-metadata", "title="+title, "-metadata", "artist=Test Artist", "-metadata", "album=Test Album", "-metadata", "genre=Rock", "-metadata", "track="+strconv.Itoa(i+1), "-metadata", "date=2021", "-codec:a", "libmp3lame", "-b:a", "64k", out) if combined, err := cmd.CombinedOutput(); err != nil { cancel() t.Fatalf("ffmpeg: %v\n%s", err, combined) } cancel() } f, err := database.CreateMusicFolder(s.DB, "Test Music", dir, "music") if err != nil { t.Fatal(err) } if err := s.Scanner.ScanFolder(context.Background(), f.ID); err != nil { t.Fatalf("ScanFolder: %v", err) } return f } func TestGetMusicFoldersAndGenresAndArtists(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) seedLibrary(t, s) q := baseParams("alice", "password123") q.Set("f", "json") resp := getPath(t, srv.URL, "/rest/getMusicFolders.view", q) j := decodeJSON(t, resp) folders := j["musicFolders"].(map[string]any)["musicFolder"].([]any) if len(folders) != 1 { t.Fatalf("musicFolders = %v, want 1", folders) } resp = getPath(t, srv.URL, "/rest/getGenres.view", q) j = decodeJSON(t, resp) genres := j["genres"].(map[string]any)["genre"].([]any) if len(genres) != 1 { t.Fatalf("genres = %v, want 1", genres) } resp = getPath(t, srv.URL, "/rest/getArtists.view", q) j = decodeJSON(t, resp) indexes := j["artists"].(map[string]any)["index"].([]any) if len(indexes) == 0 { t.Fatalf("artists index = %v, want at least 1 letter group", indexes) } } func TestGetArtistAndAlbumAndSong(t *testing.T) { s, db := newTestServer(t) createTestUser(t, s, "alice", "password123", false) seedLibrary(t, s) srv := httptest.NewServer(s.Router()) defer srv.Close() artists, err := database.ListArtists(db) if err != nil || len(artists) == 0 { t.Fatalf("ListArtists: %v %v", artists, err) } artistID := artists[0].ID q := baseParams("alice", "password123") q.Set("f", "json") q.Set("id", artistID) resp := getPath(t, srv.URL, "/rest/getArtist.view", q) j := decodeJSON(t, resp) artist := j["artist"].(map[string]any) albums := artist["album"].([]any) if len(albums) != 1 { t.Fatalf("albums = %v, want 1", albums) } albumID := albums[0].(map[string]any)["id"].(string) q.Set("id", albumID) resp = getPath(t, srv.URL, "/rest/getAlbum.view", q) j = decodeJSON(t, resp) album := j["album"].(map[string]any) songs := album["song"].([]any) if len(songs) != 2 { t.Fatalf("songs = %v, want 2", songs) } songID := songs[0].(map[string]any)["id"].(string) q.Set("id", songID) resp = getPath(t, srv.URL, "/rest/getSong.view", q) j = decodeJSON(t, resp) song := j["song"].(map[string]any) if song["title"] == nil { t.Fatalf("song missing title: %v", song) } } func TestGetAlbumNotFound(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := baseParams("alice", "password123") q.Set("id", "al_doesnotexist") resp := getPath(t, srv.URL, "/rest/getAlbum.view", q) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound { t.Fatalf("expected error 70, got %+v", x) } } func TestGetAlbumMissingID(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) resp := getPath(t, srv.URL, "/rest/getAlbum.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 TestGetIndexesAndMusicDirectory(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) seedLibrary(t, s) q := baseParams("alice", "password123") resp := getPath(t, srv.URL, "/rest/getIndexes.view", q) defer resp.Body.Close() var out struct { Indexes struct { Index []struct { Artist []struct { ID string `xml:"id,attr"` Name string `xml:"name,attr"` } `xml:"artist"` } `xml:"index"` } `xml:"indexes"` } if err := xml.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } if len(out.Indexes.Index) == 0 || len(out.Indexes.Index[0].Artist) == 0 { t.Fatalf("expected at least one artist directory, got %+v", out) } dirID := out.Indexes.Index[0].Artist[0].ID q2 := baseParams("alice", "password123") q2.Set("id", dirID) resp2 := getPath(t, srv.URL, "/rest/getMusicDirectory.view", q2) defer resp2.Body.Close() var dirOut struct { Directory struct { Child []struct { IsDir bool `xml:"isDir,attr"` Title string `xml:"title,attr"` } `xml:"child"` } `xml:"directory"` } if err := xml.NewDecoder(resp2.Body).Decode(&dirOut); err != nil { t.Fatalf("decode: %v", err) } if len(dirOut.Directory.Child) != 1 || !dirOut.Directory.Child[0].IsDir { t.Fatalf("expected one subdirectory (the album), got %+v", dirOut) } } func TestGetMusicDirectoryUnauthorizedFolder(t *testing.T) { s, db := newTestServer(t) u := createTestUser(t, s, "alice", "password123", false) folder := seedLibrary(t, s) srv := httptest.NewServer(s.Router()) defer srv.Close() // Restrict alice to a different (nonexistent) folder set, i.e. no access to `folder`. other, err := database.CreateMusicFolder(db, "Other", t.TempDir(), "music") if err != nil { t.Fatal(err) } if err := database.SetAllowedMusicFolders(db, u.ID, []string{other.ID}); err != nil { t.Fatal(err) } q := baseParams("alice", "password123") q.Set("id", encodeDirID(folder.ID, "Test Artist")) resp := getPath(t, srv.URL, "/rest/getMusicDirectory.view", q) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized { t.Fatalf("expected error 50 for restricted folder, got %+v", x) } } func TestGetVideosEmpty(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/getVideos.view", q) j := decodeJSON(t, resp) if j["status"] != "ok" { t.Fatalf("getVideos status = %v", j["status"]) } } func TestGetTopSongsUnknownArtist(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := baseParams("alice", "password123") q.Set("artist", "Nonexistent Artist XYZ") resp := getPath(t, srv.URL, "/rest/getTopSongs.view", q) x := decodeXML(t, resp) if x.Status != "ok" { t.Fatalf("getTopSongs for unknown artist should return ok with empty list, got %+v", x) } }