mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-18 23:25:30 -05:00
Compare commits
1 Commits
custom-col
...
claude/api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68591dab06 |
184
server/nativeapi/apimodel/song.go
Normal file
184
server/nativeapi/apimodel/song.go
Normal 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
|
||||
}
|
||||
132
server/nativeapi/apimodel/song_test.go
Normal file
132
server/nativeapi/apimodel/song_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
49
server/nativeapi/mapped_repository.go
Normal file
49
server/nativeapi/mapped_repository.go
Normal 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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user