Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
68591dab06 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
2026-02-15 21:28:18 +00:00
5 changed files with 410 additions and 9 deletions

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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())