From 68591dab06959d2827884138703ea1ad74865f47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 21:28:18 +0000 Subject: [PATCH] Add API abstraction layer for mediafiles in native API Introduce a mapping layer between internal model.MediaFile and the API response type. The native API /song endpoint now returns apimodel.Song instead of exposing the database model directly. This decouples the API surface from the persistence layer, enabling calculated fields and hiding internal details (Path, FolderID, LibraryPath, PID, etc.). The architecture is designed for expansion to other models: - mappedRepository: generic REST repository wrapper that transforms Read/ReadAll results through mapping functions - apimodel package: dedicated API response types with explicit field selection and JSON tags independent of the model https://claude.ai/code/session_01TvZ2CgPPfFoxzNYoWuPUBg --- server/nativeapi/apimodel/song.go | 184 +++++++++++++++++++++++ server/nativeapi/apimodel/song_test.go | 132 ++++++++++++++++ server/nativeapi/mapped_repository.go | 49 ++++++ server/nativeapi/native_api.go | 21 ++- server/nativeapi/native_api_song_test.go | 33 +++- 5 files changed, 410 insertions(+), 9 deletions(-) create mode 100644 server/nativeapi/apimodel/song.go create mode 100644 server/nativeapi/apimodel/song_test.go create mode 100644 server/nativeapi/mapped_repository.go 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())