diff --git a/server/nativeapi/apimodel/song.go b/server/nativeapi/apimodel/song.go new file mode 100644 index 000000000..05627531a --- /dev/null +++ b/server/nativeapi/apimodel/song.go @@ -0,0 +1,184 @@ +package apimodel + +import ( + "time" + + "github.com/navidrome/navidrome/model" +) + +// Song is the API representation of a media file. It decouples the API response +// from the internal model.MediaFile, allowing us to expose calculated fields, +// hide internal details, and evolve the API independently of the database schema. +type Song struct { + ID string `json:"id"` + LibraryID int `json:"libraryId"` + LibraryName string `json:"libraryName"` + + Title string `json:"title"` + Album string `json:"album"` + AlbumID string `json:"albumId"` + Artist string `json:"artist"` + ArtistID string `json:"artistId"` + AlbumArtist string `json:"albumArtist"` + AlbumArtistID string `json:"albumArtistId"` + Compilation bool `json:"compilation"` + TrackNumber int `json:"trackNumber"` + DiscNumber int `json:"discNumber"` + DiscSubtitle string `json:"discSubtitle,omitempty"` + + // Dates + Year int `json:"year"` + Date string `json:"date,omitempty"` + OriginalYear int `json:"originalYear"` + OriginalDate string `json:"originalDate,omitempty"` + ReleaseYear int `json:"releaseYear"` + ReleaseDate string `json:"releaseDate,omitempty"` + + // Audio properties + Duration float32 `json:"duration"` + Size int64 `json:"size"` + Suffix string `json:"suffix"` + BitRate int `json:"bitRate"` + SampleRate int `json:"sampleRate"` + BitDepth int `json:"bitDepth"` + Channels int `json:"channels"` + + // Metadata + Genre string `json:"genre"` + Genres model.Genres `json:"genres,omitempty"` + Comment string `json:"comment,omitempty"` + BPM int `json:"bpm,omitempty"` + ExplicitStatus string `json:"explicitStatus"` + CatalogNum string `json:"catalogNum,omitempty"` + Tags model.Tags `json:"tags,omitempty"` + Participants model.Participants `json:"participants"` + + // Sort fields + SortTitle string `json:"sortTitle,omitempty"` + SortAlbumName string `json:"sortAlbumName,omitempty"` + SortArtistName string `json:"sortArtistName,omitempty"` + SortAlbumArtistName string `json:"sortAlbumArtistName,omitempty"` + + // MusicBrainz IDs + MbzRecordingID string `json:"mbzRecordingID,omitempty"` + MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + MbzArtistID string `json:"mbzArtistId,omitempty"` + MbzAlbumArtistID string `json:"mbzAlbumArtistId,omitempty"` + MbzAlbumType string `json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` + + // ReplayGain + RGAlbumGain *float64 `json:"rgAlbumGain"` + RGAlbumPeak *float64 `json:"rgAlbumPeak"` + RGTrackGain *float64 `json:"rgTrackGain"` + RGTrackPeak *float64 `json:"rgTrackPeak"` + + // Lyrics + Lyrics string `json:"lyrics"` + + HasCoverArt bool `json:"hasCoverArt"` + + // User annotations + PlayCount int64 `json:"playCount,omitempty"` + PlayDate *time.Time `json:"playDate,omitempty"` + Rating int `json:"rating,omitempty"` + Starred bool `json:"starred,omitempty"` + StarredAt *time.Time `json:"starredAt,omitempty"` + + // Bookmark + BookmarkPosition int64 `json:"bookmarkPosition"` + + // Timestamps + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// FromMediaFile converts a model.MediaFile into the API Song representation. +func FromMediaFile(mf model.MediaFile) Song { + return Song{ + ID: mf.ID, + LibraryID: mf.LibraryID, + LibraryName: mf.LibraryName, + + Title: mf.Title, + Album: mf.Album, + AlbumID: mf.AlbumID, + Artist: mf.Artist, + ArtistID: mf.ArtistID, + AlbumArtist: mf.AlbumArtist, + AlbumArtistID: mf.AlbumArtistID, + Compilation: mf.Compilation, + TrackNumber: mf.TrackNumber, + DiscNumber: mf.DiscNumber, + DiscSubtitle: mf.DiscSubtitle, + + Year: mf.Year, + Date: mf.Date, + OriginalYear: mf.OriginalYear, + OriginalDate: mf.OriginalDate, + ReleaseYear: mf.ReleaseYear, + ReleaseDate: mf.ReleaseDate, + + Duration: mf.Duration, + Size: mf.Size, + Suffix: mf.Suffix, + BitRate: mf.BitRate, + SampleRate: mf.SampleRate, + BitDepth: mf.BitDepth, + Channels: mf.Channels, + + Genre: mf.Genre, + Genres: mf.Genres, + Comment: mf.Comment, + BPM: mf.BPM, + ExplicitStatus: mf.ExplicitStatus, + CatalogNum: mf.CatalogNum, + Tags: mf.Tags, + Participants: mf.Participants, + + SortTitle: mf.SortTitle, + SortAlbumName: mf.SortAlbumName, + SortArtistName: mf.SortArtistName, + SortAlbumArtistName: mf.SortAlbumArtistName, + + MbzRecordingID: mf.MbzRecordingID, + MbzReleaseTrackID: mf.MbzReleaseTrackID, + MbzAlbumID: mf.MbzAlbumID, + MbzReleaseGroupID: mf.MbzReleaseGroupID, + MbzArtistID: mf.MbzArtistID, + MbzAlbumArtistID: mf.MbzAlbumArtistID, + MbzAlbumType: mf.MbzAlbumType, + MbzAlbumComment: mf.MbzAlbumComment, + + RGAlbumGain: mf.RGAlbumGain, + RGAlbumPeak: mf.RGAlbumPeak, + RGTrackGain: mf.RGTrackGain, + RGTrackPeak: mf.RGTrackPeak, + + Lyrics: mf.Lyrics, + + HasCoverArt: mf.HasCoverArt, + + PlayCount: mf.PlayCount, + PlayDate: mf.PlayDate, + Rating: mf.Rating, + Starred: mf.Starred, + StarredAt: mf.StarredAt, + + BookmarkPosition: mf.BookmarkPosition, + + CreatedAt: mf.CreatedAt, + UpdatedAt: mf.UpdatedAt, + } +} + +// FromMediaFiles converts a slice of model.MediaFile into a slice of API Songs. +func FromMediaFiles(mfs model.MediaFiles) []Song { + songs := make([]Song, len(mfs)) + for i, mf := range mfs { + songs[i] = FromMediaFile(mf) + } + return songs +} diff --git a/server/nativeapi/apimodel/song_test.go b/server/nativeapi/apimodel/song_test.go new file mode 100644 index 000000000..ac001da22 --- /dev/null +++ b/server/nativeapi/apimodel/song_test.go @@ -0,0 +1,132 @@ +package apimodel_test + +import ( + "testing" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/nativeapi/apimodel" +) + +func TestFromMediaFile(t *testing.T) { + now := time.Now() + playDate := now.Add(-time.Hour) + starredAt := now.Add(-2 * time.Hour) + rgGain := 1.5 + rgPeak := 0.9 + + mf := model.MediaFile{ + ID: "mf-1", + LibraryID: 1, + LibraryName: "My Music", + LibraryPath: "/music", + FolderID: "folder-1", + Path: "/music/song.mp3", + Title: "Test Song", + Album: "Test Album", + AlbumID: "album-1", + Artist: "Test Artist", + ArtistID: "artist-1", + AlbumArtist: "Test Album Artist", + Duration: 180.5, + Size: 5242880, + Suffix: "mp3", + BitRate: 320, + SampleRate: 44100, + BitDepth: 16, + Channels: 2, + Year: 2024, + TrackNumber: 3, + DiscNumber: 1, + Genre: "Rock", + HasCoverArt: true, + RGAlbumGain: &rgGain, + RGAlbumPeak: &rgPeak, + CreatedAt: now, + UpdatedAt: now, + } + mf.PlayCount = 5 + mf.PlayDate = &playDate + mf.Starred = true + mf.StarredAt = &starredAt + mf.Rating = 4 + mf.BookmarkPosition = 1000 + + song := apimodel.FromMediaFile(mf) + + // Verify mapped fields + if song.ID != mf.ID { + t.Errorf("ID: got %q, want %q", song.ID, mf.ID) + } + if song.Title != mf.Title { + t.Errorf("Title: got %q, want %q", song.Title, mf.Title) + } + if song.Artist != mf.Artist { + t.Errorf("Artist: got %q, want %q", song.Artist, mf.Artist) + } + if song.Album != mf.Album { + t.Errorf("Album: got %q, want %q", song.Album, mf.Album) + } + if song.Duration != mf.Duration { + t.Errorf("Duration: got %v, want %v", song.Duration, mf.Duration) + } + if song.BitRate != mf.BitRate { + t.Errorf("BitRate: got %d, want %d", song.BitRate, mf.BitRate) + } + if song.LibraryID != mf.LibraryID { + t.Errorf("LibraryID: got %d, want %d", song.LibraryID, mf.LibraryID) + } + if song.LibraryName != mf.LibraryName { + t.Errorf("LibraryName: got %q, want %q", song.LibraryName, mf.LibraryName) + } + + // Verify annotations are mapped + if song.PlayCount != mf.PlayCount { + t.Errorf("PlayCount: got %d, want %d", song.PlayCount, mf.PlayCount) + } + if song.Starred != mf.Starred { + t.Errorf("Starred: got %v, want %v", song.Starred, mf.Starred) + } + if song.Rating != mf.Rating { + t.Errorf("Rating: got %d, want %d", song.Rating, mf.Rating) + } + + // Verify bookmark is mapped + if song.BookmarkPosition != mf.BookmarkPosition { + t.Errorf("BookmarkPosition: got %d, want %d", song.BookmarkPosition, mf.BookmarkPosition) + } + + // Verify replay gain pointers are mapped + if song.RGAlbumGain == nil || *song.RGAlbumGain != rgGain { + t.Errorf("RGAlbumGain: got %v, want %v", song.RGAlbumGain, &rgGain) + } +} + +func TestFromMediaFiles(t *testing.T) { + mfs := model.MediaFiles{ + {ID: "1", Title: "Song 1"}, + {ID: "2", Title: "Song 2"}, + {ID: "3", Title: "Song 3"}, + } + + songs := apimodel.FromMediaFiles(mfs) + + if len(songs) != 3 { + t.Fatalf("expected 3 songs, got %d", len(songs)) + } + for i, song := range songs { + if song.ID != mfs[i].ID { + t.Errorf("song[%d].ID: got %q, want %q", i, song.ID, mfs[i].ID) + } + if song.Title != mfs[i].Title { + t.Errorf("song[%d].Title: got %q, want %q", i, song.Title, mfs[i].Title) + } + } +} + +func TestFromMediaFilesEmpty(t *testing.T) { + songs := apimodel.FromMediaFiles(model.MediaFiles{}) + if len(songs) != 0 { + t.Fatalf("expected 0 songs, got %d", len(songs)) + } +} diff --git a/server/nativeapi/mapped_repository.go b/server/nativeapi/mapped_repository.go new file mode 100644 index 000000000..2776c6d9b --- /dev/null +++ b/server/nativeapi/mapped_repository.go @@ -0,0 +1,49 @@ +package nativeapi + +import ( + "github.com/deluan/rest" +) + +// mappedRepository wraps a rest.Repository and transforms its Read/ReadAll results +// using a mapping function. This allows the native API to return different types than +// what the persistence layer provides, enabling calculated fields and decoupling the +// API response shape from the database model. +// +// The mapFunc receives the raw result from the underlying repository and returns the +// transformed result for the API response. It handles both single items (from Read) +// and collections (from ReadAll). +type mappedRepository struct { + repo rest.Repository + mapOne func(any) (any, error) + mapMany func(any) (any, error) +} + +func (m *mappedRepository) Count(options ...rest.QueryOptions) (int64, error) { + return m.repo.Count(options...) +} + +func (m *mappedRepository) Read(id string) (any, error) { + result, err := m.repo.Read(id) + if err != nil { + return nil, err + } + return m.mapOne(result) +} + +func (m *mappedRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + result, err := m.repo.ReadAll(options...) + if err != nil { + return nil, err + } + return m.mapMany(result) +} + +func (m *mappedRepository) EntityName() string { + return m.repo.EntityName() +} + +func (m *mappedRepository) NewInstance() any { + return m.repo.NewInstance() +} + +var _ rest.Repository = (*mappedRepository)(nil) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 91ddd0fa3..704450d2e 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/nativeapi/apimodel" ) // PluginManager defines the interface for plugin management operations. @@ -63,7 +64,7 @@ func (api *Router) routes() http.Handler { r.Use(server.JWTRefresher) r.Use(server.UpdateLastAccessMiddleware(api.ds)) api.RX(r, "/user", api.users.NewRepository, true) - api.R(r, "/song", model.MediaFile{}, false) + api.addSongRoute(r) api.R(r, "/album", model.Album{}, false) api.R(r, "/artist", model.Artist{}, false) api.R(r, "/genre", model.Genre{}, false) @@ -102,6 +103,24 @@ func (api *Router) R(r chi.Router, pathPrefix string, model any, persistable boo api.RX(r, pathPrefix, constructor, persistable) } +func (api *Router) addSongRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + repo := api.ds.Resource(ctx, model.MediaFile{}) + return &mappedRepository{ + repo: repo, + mapOne: func(v any) (any, error) { + mf := v.(*model.MediaFile) + return apimodel.FromMediaFile(*mf), nil + }, + mapMany: func(v any) (any, error) { + mfs := v.(model.MediaFiles) + return apimodel.FromMediaFiles(mfs), nil + }, + } + } + api.RX(r, "/song", constructor, false) +} + func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) { r.Route(pathPrefix, func(r chi.Router) { r.Get("/", rest.GetAll(constructor)) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b192e00ac..f9e319022 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/nativeapi/apimodel" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -127,13 +128,13 @@ var _ = Describe("Song Endpoints", func() { Describe("GET /song", func() { Context("when user is authenticated", func() { - It("returns all songs", func() { + It("returns all songs as apimodel.Song types", func() { req := createAuthenticatedRequest("GET", "/song", nil) router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusOK)) - var response []model.MediaFile + var response []apimodel.Song err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).ToNot(HaveOccurred()) @@ -144,6 +145,22 @@ var _ = Describe("Song Endpoints", func() { Expect(response[1].Title).To(Equal("Test Song 2")) }) + It("does not expose internal model fields like Path", func() { + req := createAuthenticatedRequest("GET", "/song", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + // Parse into a raw map to check that Path is not present + var rawResponse []map[string]any + err := json.Unmarshal(w.Body.Bytes(), &rawResponse) + Expect(err).ToNot(HaveOccurred()) + Expect(rawResponse).To(HaveLen(2)) + Expect(rawResponse[0]).ToNot(HaveKey("path")) + Expect(rawResponse[0]).ToNot(HaveKey("folderId")) + Expect(rawResponse[0]).ToNot(HaveKey("libraryPath")) + }) + It("handles repository errors gracefully", func() { mfRepo.SetError(true) @@ -166,13 +183,13 @@ var _ = Describe("Song Endpoints", func() { Describe("GET /song/{id}", func() { Context("when user is authenticated", func() { - It("returns the specific song", func() { + It("returns the specific song as apimodel.Song type", func() { req := createAuthenticatedRequest("GET", "/song/song-1", nil) router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusOK)) - var response model.MediaFile + var response apimodel.Song err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).ToNot(HaveOccurred()) @@ -265,7 +282,7 @@ var _ = Describe("Song Endpoints", func() { Expect(w.Code).To(Equal(http.StatusOK)) - var response []model.MediaFile + var response []apimodel.Song err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).ToNot(HaveOccurred()) @@ -280,7 +297,7 @@ var _ = Describe("Song Endpoints", func() { Expect(w.Code).To(Equal(http.StatusOK)) - var response []model.MediaFile + var response []apimodel.Song err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).ToNot(HaveOccurred()) @@ -299,7 +316,7 @@ var _ = Describe("Song Endpoints", func() { Expect(w.Code).To(Equal(http.StatusOK)) - var response []model.MediaFile + var response []apimodel.Song err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).ToNot(HaveOccurred()) @@ -349,7 +366,7 @@ var _ = Describe("Song Endpoints", func() { Expect(w.Code).To(Equal(http.StatusOK)) - var response []model.MediaFile + var response []apimodel.Song err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).ToNot(HaveOccurred())