From ba7fd137244567508810f28a9bbd7a2f32168e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 20 May 2025 12:37:27 -0400 Subject: [PATCH] feat(subsonic): add ISRC support for OpenSubsonic Child (#4088) * docs: add testing and logging guidelines to AGENTS.md Signed-off-by: Deluan * Introduce TagISRC and update ISRC handling * fix: update .gitignore to exclude executable files and bin directory Signed-off-by: Deluan --------- Signed-off-by: Deluan --- .gitignore | 3 +- AGENTS.md | 110 ++++++++++++++++++ model/tag.go | 1 + server/subsonic/helpers.go | 1 + ... AlbumList with OS data should match .JSON | 1 + ...mWithSongsID3 with data should match .JSON | 3 + ...umWithSongsID3 with data should match .XML | 1 + ...sponses Child with data should match .JSON | 4 + ...esponses Child with data should match .XML | 2 + ...thout data should match OpenSubsonic .JSON | 1 + server/subsonic/responses/responses.go | 1 + server/subsonic/responses/responses_test.go | 4 +- 12 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 27b23240..c04f5b15 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ docker-compose.yml !contrib/docker-compose.yml binaries navidrome-master -*.exe \ No newline at end of file +*.exe +bin/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..272426e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# Testing Instructions + +- **No implementation task is considered complete until it includes thorough, passing tests that cover the new or + changed functionality. All new code must be accompanied by Ginkgo/Gomega tests, and PRs/commits without tests should + be considered incomplete.** +- All Go tests in this project **MUST** be written using the **Ginkgo v2** and **Gomega** frameworks. +- To run all tests, use `make test`. +- To run tests for a specific package, use `make test PKG=./pkgname/...` +- Do not run tests in parallel +- Don't use `--fail-on-pending` + +## Mocking Convention + +- Always try to use the mocks provided in the `tests` package before creating a new mock implementation. +- Only create a new mock if the required functionality is not covered by the existing mocks in `tests`. +- Never mock a real implementation when testing. Remember: there is no value in testing an interface, only the real implementation. + +## Example + +Every package that you write tests for, should have a `*_suite_test.go` file, to hook up the Ginkgo test suite. Example: +``` +package core + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCore(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Core Suite") +} +``` +Never put a `func Test*` in regular *_test.go files, only in `*_suite_test.go` files. + +Refer to existing test suites for examples of proper setup and usage, such as the one defined in @core_suite_test.go + +## Exceptions + +There should be no exceptions to this rule. If you encounter tests written with the standard `testing` package or other frameworks, they should be refactored to use Ginkgo/Gomega. If you need a new mock, first confirm that it does not already exist in the `tests` package. + +### Configuration + +You can set config values in the BeforeEach/BeforeAll blocks. If you do so, remember to add `DeferCleanup(configtest.SetupConfig())` to reset the values. Example: + +```go +BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableDownloads = true +}) +``` + +# Logging System Usage Guide + +This project uses a custom logging system built on top of logrus, `log/log.go`. Follow these conventions for all logging: + +## Logging API +- Use the provided functions for logging at different levels: + - `Error(...)`, `Warn(...)`, `Info(...)`, `Debug(...)`, `Trace(...)`, `Fatal(...)` +- These functions accept flexible arguments: + - The first argument can be a context (`context.Context`), an HTTP request, or `nil`. + - The next argument is the log message (string or error). + - Additional arguments are key-value pairs (e.g., `"key", value`). + - If the last argument is an error, it is logged under the `error` key. + +**Examples:** +```go +log.Error("A message") +log.Error(ctx, "A message with context") +log.Error("Failed to save", "id", 123, err) +log.Info(req, "Request received", "user", userID) +``` + +## Logging errors +- You don't need to add "err" key when logging an error, it is automatically added. +- Error must always be the last parameter in the log call. + Examples: +```go +log.Error("Failed to save", "id", 123, err) // GOOD +log.Error("Failed to save", "id", 123, "err", err) // BAD +log.Error("Failed to save", err, "id", 123) // BAD +``` + +## Context and Request Logging +- If a context or HTTP request is passed as the first argument, any logger fields in the context are included in the log entry. +- Use `log.NewContext(ctx, "key", value, ...)` to add fields to a context for logging. + +## Log Levels +- Set the global log level with `log.SetLevel(log.LevelInfo)` or `log.SetLevelString("info")`. +- Per-path log levels can be set with `log.SetLogLevels(map[string]string{"path": "level"})`. +- Use `log.IsGreaterOrEqualTo(level)` to check if a log level is enabled for the current code path. + +## Source Line Logging +- Enable source file/line logging with `log.SetLogSourceLine(true)`. + +## Best Practices +- Always use the logging API, never log directly with logrus or fmt. +- Prefer structured logging (key-value pairs) for important data. +- Use context/request logging for traceability in web handlers. +- For tests, use Ginkgo/Gomega and set up a test logger as in `log/log_test.go`. + +## See Also +- `log/log.go` for implementation details +- `log/log_test.go` for usage examples and test patterns diff --git a/model/tag.go b/model/tag.go index a9864e0b..a1f4e28d 100644 --- a/model/tag.go +++ b/model/tag.go @@ -191,6 +191,7 @@ const ( TagReleaseCountry TagName = "releasecountry" TagMedia TagName = "media" TagCatalogNumber TagName = "catalognumber" + TagISRC TagName = "isrc" TagBPM TagName = "bpm" TagExplicitStatus TagName = "explicitstatus" diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 4faec158..39f32465 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -224,6 +224,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op child.BPM = int32(mf.BPM) child.MediaType = responses.MediaTypeSong child.MusicBrainzId = mf.MbzRecordingID + child.Isrc = mf.Tags.Values(model.TagISRC) child.ReplayGain = responses.ReplayGain{ TrackGain: mf.RGTrackGain, AlbumGain: mf.RGAlbumGain, diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON index 0db35c37..0e6425f6 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON @@ -15,6 +15,7 @@ "sortName": "sort name", "mediaType": "album", "musicBrainzId": "00000000-0000-0000-0000-000000000000", + "isrc": [], "genres": [ { "name": "Genre 1" diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index c3ae3ee2..78b5c6e7 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -99,6 +99,9 @@ "sortName": "sorted song", "mediaType": "song", "musicBrainzId": "4321", + "isrc": [ + "ISRC-1" + ], "genres": [ { "name": "rock" diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index a02c0fee..f3281d9e 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -16,6 +16,7 @@ + ISRC-1 diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index 13aa1f18..d64ae9e7 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -30,6 +30,10 @@ "sortName": "sorted title", "mediaType": "song", "musicBrainzId": "4321", + "isrc": [ + "ISRC-1", + "ISRC-2" + ], "genres": [ { "name": "rock" diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index 477892ac..639fd3f6 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -1,6 +1,8 @@ + ISRC-1 + ISRC-2 diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON index 5dc0e8eb..1af2ec4a 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON @@ -15,6 +15,7 @@ "sortName": "", "mediaType": "", "musicBrainzId": "", + "isrc": [], "genres": [], "replayGain": {}, "channelCount": 0, diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 0d22ef50..e886d656 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -176,6 +176,7 @@ type OpenSubsonicChild struct { SortName string `xml:"sortName,attr,omitempty" json:"sortName"` MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"` MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"` + Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"` Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"` ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"` ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index e484ab2c..9fcd6078 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -224,7 +224,8 @@ var _ = Describe("Responses", func() { child[0].OpenSubsonicChild = &OpenSubsonicChild{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted title", - BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, + Isrc: []string{"ISRC-1", "ISRC-2"}, + BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6}, DisplayArtist: "artist 1 & artist 2", @@ -312,6 +313,7 @@ var _ = Describe("Responses", func() { songs[0].OpenSubsonicChild = &OpenSubsonicChild{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song", + Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,