diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 6f858a5a7..8d3cd3ad0 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -24,7 +24,7 @@ jobs: git_tag: ${{ steps.git-version.outputs.GIT_TAG }} git_sha: ${{ steps.git-version.outputs.GIT_SHA }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true @@ -62,7 +62,7 @@ jobs: name: Lint Go code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -101,7 +101,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -127,7 +127,7 @@ jobs: FFMPEG_VERSION: "7.1" FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -199,7 +199,7 @@ jobs: env: NODE_OPTIONS: "--max_old_space_size=4096" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: 24 @@ -230,7 +230,7 @@ jobs: name: Lint i18n files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - run: | set -e for file in resources/i18n/*.json; do @@ -276,7 +276,7 @@ jobs: PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Prepare Docker Buildx uses: ./.github/actions/prepare-docker @@ -350,7 +350,7 @@ jobs: env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Download digests uses: actions/download-artifact@v8 @@ -384,7 +384,7 @@ jobs: if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' continue-on-error: true steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Download digests uses: actions/download-artifact@v8 @@ -437,7 +437,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: @@ -471,7 +471,7 @@ jobs: outputs: package_list: ${{ steps.set-package-list.outputs.package_list }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/push-translations.yml b/.github/workflows/push-translations.yml index f7cf00621..546c64114 100644 --- a/.github/workflows/push-translations.yml +++ b/.github/workflows/push-translations.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 2 diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 8fe0b5379..5db32f3fa 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Get updated translations id: poeditor env: diff --git a/.golangci.yml b/.golangci.yml index 28eb375a5..200fe122f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -13,6 +13,7 @@ linters: - dogsled - durationcheck - errorlint + - forbidigo - gocritic - gocyclo - goprintffuncname @@ -36,6 +37,14 @@ linters: - G401 - G505 - G115 + forbidigo: + forbid: + - pattern: 'tx\.Exec$' + msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context" + - pattern: 'tx\.Query$' + msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context" + - pattern: 'tx\.QueryRow$' + msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context" govet: enable: - nilness @@ -45,6 +54,9 @@ linters: - gosec path: _test\.go text: "G703" + - path-except: 'db/migrations/' + linters: + - forbidigo generated: lax presets: - comments @@ -56,6 +68,7 @@ linters: - builtin$ - examples$ - node_modules + - _gen\.go$ formatters: exclusions: generated: lax diff --git a/Makefile b/Makefile index e303017c7..90a405de8 100644 --- a/Makefile +++ b/Makefile @@ -113,10 +113,11 @@ wire: check_go_env ##@Development Update Dependency Injection gen: check_go_env ##@Development Run go generate for code generation go generate ./... - cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host - cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust - cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust - cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities + cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust + cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types + cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types + cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types + cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types go mod tidy -C plugins/pdk/go .PHONY: gen diff --git a/README.md b/README.md index 0ae5bdfaf..4bc85e6a6 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ A share of the revenue helps fund the development of Navidrome at no additional - **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided - Ready to use binaries for all major platforms, including **Raspberry Pi** - Automatically **monitors your library** for changes, importing new files and reloading new metadata + - Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`) - **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com) - **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps) - **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported** diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 02c198120..eb8f3d36e 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -231,10 +231,9 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti res := make([]agents.Song, 0, len(resp)) for _, t := range resp { res = append(res, agents.Song{ - Name: t.Name, - MBID: t.MBID, - Artist: t.Artist.Name, - ArtistMBID: t.Artist.MBID, + Name: t.Name, + MBID: t.MBID, + Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}}, }) } return res, nil diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 94788b8bd..7e4e29294 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -309,11 +309,11 @@ var _ = Describe("lastfmAgent", func() { f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{ - {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, - {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, - {Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}, - {Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"}, - {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}, + {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, + {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, + {Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}}, + {Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}}, + {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}}, })) Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough")) diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 826a9672e..76beed921 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -141,24 +141,37 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam res := make([]agents.Song, len(resp)) for i, t := range resp { - mbid := "" - if len(t.ArtistMBIDs) > 0 { - mbid = t.ArtistMBIDs[0] - } - res[i] = agents.Song{ - Album: t.ReleaseName, - AlbumMBID: t.ReleaseMBID, - Artist: t.ArtistName, - ArtistMBID: mbid, - Duration: t.DurationMs, - Name: t.RecordingName, - MBID: t.RecordingMbid, + Album: t.ReleaseName, + AlbumMBID: t.ReleaseMBID, + Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs), + Duration: t.DurationMs, + Name: t.RecordingName, + MBID: t.RecordingMbid, } } return res, nil } +// topSongArtists maps the top-recordings response, which carries a single combined display name +// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not +// positionally pairable, so the display name attaches to the first credit and any further MBIDs +// become MBID-only collaborators — still valid identity signals for the matcher. +func topSongArtists(name string, mbids []string) []agents.Artist { + if len(mbids) == 0 { + if name == "" { + return nil + } + return []agents.Artist{{Name: name}} + } + artists := make([]agents.Artist, len(mbids)) + artists[0] = agents.Artist{Name: name, MBID: mbids[0]} + for i, m := range mbids[1:] { + artists[i+1] = agents.Artist{MBID: m} + } + return artists +} + func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) { if mbid == "" { return nil, agents.ErrNotFound @@ -203,7 +216,7 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin songs[i] = agents.Song{ Album: song.ReleaseName, AlbumMBID: song.ReleaseMBID, - Artist: song.Artist, + Artists: []agents.Artist{{Name: song.Artist}}, MBID: song.MBID, Name: song.Name, } diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index df70ec9c4..2c4668296 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -249,24 +249,22 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]agents.Song{ { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artist: "Mili", - ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, }, { - ID: "", - Name: "String Theocracy", - MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", - Artist: "Mili", - ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - Album: "String Theocracy", - AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", - Duration: 174000, + ID: "", + Name: "String Theocracy", + MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", + Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, + Album: "String Theocracy", + AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", + Duration: 174000, }, })) }) @@ -278,17 +276,45 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]agents.Song{ { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artist: "Mili", - ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, }, })) }) + + It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() { + body := `[{ + "recording_name": "Collab", + "recording_mbid": "rec-1", + "artist_name": "Drake feat. Future", + "artist_mbids": ["mbid-drake", "mbid-future"], + "release_name": "Album", + "release_mbid": "rel-1", + "length": 200000 + }]` + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(HaveLen(1)) + Expect(data[0].Artists).To(Equal([]agents.Artist{ + {Name: "Drake feat. Future", MBID: "mbid-drake"}, + {MBID: "mbid-future"}, + })) + }) + + It("leaves Artists nil when the top song carries no name or MBIDs", func() { + body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]` + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(HaveLen(1)) + Expect(data[0].Artists).To(BeNil()) + }) }) Describe("GetSimilarArtists", func() { @@ -393,26 +419,24 @@ var _ = Describe("listenBrainzAgent", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) Expect(resp).To(Equal([]agents.Song{ { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artist: "a‐ha", - ArtistMBID: "", - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artists: []agents.Artist{{Name: "a‐ha"}}, + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, }, { - ID: "", - Name: "Wake Me Up Before You Go‐Go", - MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", - ISRC: "", - Artist: "Wham!", - ArtistMBID: "", - Album: "Make It Big", - AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", - Duration: 0, + ID: "", + Name: "Wake Me Up Before You Go‐Go", + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + ISRC: "", + Artists: []agents.Artist{{Name: "Wham!"}}, + Album: "Make It Big", + AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", + Duration: 0, }, })) }) @@ -427,15 +451,14 @@ var _ = Describe("listenBrainzAgent", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) Expect(resp).To(Equal([]agents.Song{ { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artist: "a‐ha", - ArtistMBID: "", - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artists: []agents.Artist{{Name: "a‐ha"}}, + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, }, })) }) diff --git a/cmd/plugin.go b/cmd/plugin.go new file mode 100644 index 000000000..6cce8ea5f --- /dev/null +++ b/cmd/plugin.go @@ -0,0 +1,558 @@ +package cmd + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" + "github.com/spf13/cobra" +) + +// pluginManager is the subset of *plugins.Manager the CLI needs. +type pluginManager interface { + EnablePlugin(ctx context.Context, id string) error + DisablePlugin(ctx context.Context, id string) error + ValidatePluginConfig(ctx context.Context, id, configJSON string) error + UpdatePluginConfig(ctx context.Context, id, configJSON string) error + UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error + UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error + RescanPlugins(ctx context.Context) error +} + +var ( + pluginListFormat string + pluginInfoFormat string +) + +var ( + editConfig string + editConfigFile string + editUsers string + editAllUsers bool + editLibraries string + editAllLibs bool + editWriteAccess bool + editNoWrite bool +) + +func init() { + rootCmd.AddCommand(pluginRoot) + + pluginListCmd.Flags().StringVarP(&pluginListFormat, "format", "f", "table", "output format [supported values: table, csv, json]") + pluginRoot.AddCommand(pluginListCmd) + pluginRoot.AddCommand(pluginEnableCmd) + pluginRoot.AddCommand(pluginDisableCmd) + + pluginEditCmd.Flags().StringVar(&editConfig, "config", "", "plugin config as JSON") + pluginEditCmd.Flags().StringVar(&editConfigFile, "config-file", "", "read plugin config JSON from a file ('-' for stdin)") + pluginEditCmd.MarkFlagsMutuallyExclusive("config", "config-file") + pluginEditCmd.Flags().StringVar(&editUsers, "users", "", `usernames the plugin may access: comma-separated (alice,bob) or a JSON array (["alice","bob"])`) + pluginEditCmd.Flags().BoolVar(&editAllUsers, "all-users", false, "grant the plugin access to all users") + pluginEditCmd.MarkFlagsMutuallyExclusive("users", "all-users") + pluginEditCmd.Flags().StringVar(&editLibraries, "libraries", "", `library IDs the plugin may access: comma-separated (1,2) or a JSON array ([1,2])`) + pluginEditCmd.Flags().BoolVar(&editAllLibs, "all-libraries", false, "grant the plugin access to all libraries") + pluginEditCmd.MarkFlagsMutuallyExclusive("libraries", "all-libraries") + pluginEditCmd.Flags().BoolVar(&editWriteAccess, "write-access", false, "allow the plugin write access to libraries") + pluginEditCmd.Flags().BoolVar(&editNoWrite, "no-write-access", false, "deny the plugin write access to libraries") + pluginEditCmd.MarkFlagsMutuallyExclusive("write-access", "no-write-access") + pluginRoot.AddCommand(pluginEditCmd) + + pluginInfoCmd.Flags().StringVarP(&pluginInfoFormat, "format", "f", "text", "output format [supported values: text, json]") + pluginRoot.AddCommand(pluginInfoCmd) + pluginRoot.AddCommand(pluginValidateCmd) + pluginRoot.AddCommand(pluginRescanCmd) +} + +var ( + pluginRoot = &cobra.Command{ + Use: "plugin", + Short: "Manage and inspect plugins", + Long: "List, inspect, enable, disable, configure, rescan, and validate plugins", + } + + pluginListCmd = &cobra.Command{ + Use: "list", + Short: "List installed plugins", + Run: func(cmd *cobra.Command, args []string) { + runPluginList(cmd.Context()) + }, + } + + pluginEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a plugin", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + _, ctx := getAdminContext(cmd.Context()) + mgr := GetPluginManager(ctx) + if err := enablePlugin(ctx, mgr, args[0]); err != nil { + log.Fatal(ctx, "Failed to enable plugin", "id", args[0], err) + } + }, + } + + pluginDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a plugin", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + _, ctx := getAdminContext(cmd.Context()) + mgr := GetPluginManager(ctx) + if err := disablePlugin(ctx, mgr, args[0]); err != nil { + log.Fatal(ctx, "Failed to disable plugin", "id", args[0], err) + } + }, + } +) + +var ( + pluginInfoCmd = &cobra.Command{ + Use: "info ", + Short: "Show details for an installed plugin or a .ndp package", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runPluginInfo(cmd.Context(), args[0]) + }, + } + + pluginValidateCmd = &cobra.Command{ + Use: "validate ", + Short: "Validate an installed plugin or a .ndp package manifest", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runPluginValidate(cmd.Context(), args[0]) + }, + } +) + +// isPackagePath checks only the extension (not existence) so a mistyped path +// still routes to ReadManifest, which reports a precise "no such file" error. +func isPackagePath(arg string) bool { + return strings.HasSuffix(arg, plugins.PackageExtension) +} + +func formatPluginInfo(p *model.Plugin, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return "", err + } + return string(b), nil + case "text": + default: + return "", fmt.Errorf("invalid output format %q (supported: text, json)", format) + } + name, version := manifestSummary(*p) + var sb strings.Builder + fmt.Fprintf(&sb, "ID: %s\n", p.ID) + fmt.Fprintf(&sb, "Name: %s\n", name) + fmt.Fprintf(&sb, "Version: %s\n", version) + fmt.Fprintf(&sb, "Enabled: %t\n", p.Enabled) + fmt.Fprintf(&sb, "Path: %s\n", p.Path) + fmt.Fprintf(&sb, "SHA256: %s\n", p.SHA256) + fmt.Fprintf(&sb, "All users: %t\n", p.AllUsers) + fmt.Fprintf(&sb, "All libs: %t\n", p.AllLibraries) + fmt.Fprintf(&sb, "Write access: %t\n", p.AllowWriteAccess) + if p.Users != "" { + fmt.Fprintf(&sb, "Users: %s\n", p.Users) + } + if p.Libraries != "" { + fmt.Fprintf(&sb, "Libraries: %s\n", p.Libraries) + } + if m, err := plugins.ParseManifest([]byte(p.Manifest)); err == nil { + if perms := m.Permissions.DeclaredNames(); len(perms) > 0 { + fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", ")) + } + } + if !p.CreatedAt.IsZero() { + fmt.Fprintf(&sb, "Created: %s\n", p.CreatedAt.Format(time.RFC3339)) + } + if !p.UpdatedAt.IsZero() { + fmt.Fprintf(&sb, "Updated: %s\n", p.UpdatedAt.Format(time.RFC3339)) + } + if p.Config != "" { + fmt.Fprintf(&sb, "Config: %s\n", p.Config) + } + if p.LastError != "" { + fmt.Fprintf(&sb, "Last error: %s\n", p.LastError) + } + return sb.String(), nil +} + +func formatManifestInfo(m *plugins.Manifest, sha256, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(struct { + *plugins.Manifest + SHA256 string `json:"sha256"` + }{m, sha256}, "", " ") + if err != nil { + return "", err + } + return string(b), nil + case "text": + default: + return "", fmt.Errorf("invalid output format %q (supported: text, json)", format) + } + var sb strings.Builder + fmt.Fprintf(&sb, "Name: %s\n", m.Name) + fmt.Fprintf(&sb, "Version: %s\n", m.Version) + fmt.Fprintf(&sb, "Author: %s\n", m.Author) + if m.Description != nil { + fmt.Fprintf(&sb, "Description: %s\n", *m.Description) + } + if m.Website != nil { + fmt.Fprintf(&sb, "Website: %s\n", *m.Website) + } + if perms := m.Permissions.DeclaredNames(); len(perms) > 0 { + fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", ")) + } + fmt.Fprintf(&sb, "SHA256: %s\n", sha256) + return sb.String(), nil +} + +func runPluginInfo(ctx context.Context, arg string) { + if isPackagePath(arg) { + m, err := plugins.ReadManifest(arg) + if err != nil { + log.Fatal(ctx, "Failed to read package", "path", arg, err) + } + sha, err := plugins.ComputeFileSHA256(arg) + if err != nil { + log.Fatal(ctx, "Failed to hash package", "path", arg, err) + } + out, err := formatManifestInfo(m, sha, pluginInfoFormat) + if err != nil { + log.Fatal(ctx, "Failed to format output", err) + } + fmt.Print(out) + return + } + requirePluginsEnabled(ctx) + ds, ctx := getAdminContext(ctx) + p, err := ds.Plugin(ctx).Get(arg) + if err != nil { + log.Fatal(ctx, "Plugin not found", "id", arg, err) + } + out, err := formatPluginInfo(p, pluginInfoFormat) + if err != nil { + log.Fatal(ctx, "Failed to format output", err) + } + fmt.Print(out) +} + +func runPluginValidate(ctx context.Context, arg string) { + if isPackagePath(arg) { + if _, err := plugins.ReadManifest(arg); err != nil { + log.Fatal(ctx, "Validation failed", "path", arg, err) + } + fmt.Printf("%s: OK\n", arg) + return + } + requirePluginsEnabled(ctx) + ds, ctx := getAdminContext(ctx) + p, err := ds.Plugin(ctx).Get(arg) + if err != nil { + log.Fatal(ctx, "Plugin not found", "id", arg, err) + } + if _, err := plugins.ParseManifest([]byte(p.Manifest)); err != nil { + log.Fatal(ctx, "Validation failed", "id", arg, err) + } + if p.Config != "" { + mgr := GetPluginManager(ctx) + if err := mgr.ValidatePluginConfig(ctx, arg, p.Config); err != nil { + log.Fatal(ctx, "Config validation failed", "id", arg, err) + } + } + fmt.Printf("%s: OK\n", arg) +} + +// manifestSummary extracts the display name and version from a stored manifest JSON, falling +// back to the plugin ID when the manifest can't be parsed. +func manifestSummary(p model.Plugin) (name, version string) { + var m struct { + Name string `json:"name"` + Version string `json:"version"` + } + if err := json.Unmarshal([]byte(p.Manifest), &m); err != nil { + return p.ID, "" + } + return m.Name, m.Version +} + +func formatPluginList(list model.Plugins, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return "", err + } + return string(b), nil + case "csv": + var sb strings.Builder + w := csv.NewWriter(&sb) + _ = w.Write([]string{"id", "name", "version", "enabled", "last error"}) + for _, p := range list { + name, version := manifestSummary(p) + _ = w.Write([]string{p.ID, name, version, fmt.Sprintf("%t", p.Enabled), p.LastError}) + } + w.Flush() + return sb.String(), w.Error() + case "table": + var sb strings.Builder + w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR") + for _, p := range list { + name, version := manifestSummary(p) + fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n", p.ID, name, version, p.Enabled, p.LastError) + } + w.Flush() + return sb.String(), nil + default: + return "", fmt.Errorf("invalid output format %q (supported: table, csv, json)", format) + } +} + +func runPluginList(ctx context.Context) { + requirePluginsEnabled(ctx) + ds, ctx := getAdminContext(ctx) + list, err := ds.Plugin(ctx).GetAll() + if err != nil { + log.Fatal(ctx, "Failed to list plugins", err) + } + out, err := formatPluginList(list, pluginListFormat) + if err != nil { + log.Fatal(ctx, "Failed to format output", err) + } + fmt.Print(out) +} + +// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp +// inspection deliberately skips this so it works without a configured server. +func requirePluginsEnabled(ctx context.Context) { + if !conf.Server.Plugins.Enabled { + log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)") + } +} + +func enablePlugin(ctx context.Context, mgr pluginManager, id string) error { + return mgr.EnablePlugin(ctx, id) +} + +func disablePlugin(ctx context.Context, mgr pluginManager, id string) error { + return mgr.DisablePlugin(ctx, id) +} + +type pluginEditOptions struct { + config *string // nil = leave unchanged + users *string + allUsers *bool + libraries *string + allLibraries *bool + writeAccess *bool +} + +var pluginEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Update a plugin's config and/or permissions", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + ds, ctx := getAdminContext(cmd.Context()) + cur, err := ds.Plugin(ctx).Get(args[0]) + if err != nil { + log.Fatal(ctx, "Plugin not found", "id", args[0], err) + } + mgr := GetPluginManager(ctx) + opts := buildEditOptionsFromFlags(ctx, cmd) + if err := applyPluginEdit(ctx, mgr, cur, opts); err != nil { + log.Fatal(ctx, "Failed to edit plugin", "id", args[0], err) + } + }, +} + +func buildEditOptionsFromFlags(ctx context.Context, cmd *cobra.Command) pluginEditOptions { + var opts pluginEditOptions + switch { + case cmd.Flags().Changed("config"): + c := editConfig + opts.config = &c + case cmd.Flags().Changed("config-file"): + c := readConfigFile(ctx, editConfigFile) + opts.config = &c + } + if cmd.Flags().Changed("users") { + u := editUsers + opts.users = &u + } + if cmd.Flags().Changed("all-users") { + v := editAllUsers + opts.allUsers = &v + } + if cmd.Flags().Changed("libraries") { + l := editLibraries + opts.libraries = &l + } + if cmd.Flags().Changed("all-libraries") { + v := editAllLibs + opts.allLibraries = &v + } + if cmd.Flags().Changed("write-access") || cmd.Flags().Changed("no-write-access") { + // write-access is part of the library-permission group, so it is updated + // alongside the (preserved) library list rather than on its own. + wa := editWriteAccess && !editNoWrite + opts.writeAccess = &wa + } + return opts +} + +func readConfigFile(ctx context.Context, path string) string { + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + log.Fatal(ctx, "Failed to read config file", "path", path, err) + } + return string(data) +} + +// applyPluginEdit applies the requested changes on top of the plugin's current +// state. Like the native API, it reads the existing users/libraries before +// updating so that flipping one flag (e.g. --write-access) does not wipe +// unspecified fields, and rejects non-JSON users/libraries values. +func applyPluginEdit(ctx context.Context, mgr pluginManager, cur *model.Plugin, opts pluginEditOptions) error { + if opts.config == nil && opts.users == nil && opts.allUsers == nil && + opts.libraries == nil && opts.allLibraries == nil && opts.writeAccess == nil { + return fmt.Errorf("nothing to update: provide at least one of --config/--users/--libraries/--write-access") + } + id := cur.ID + if opts.config != nil { + if err := mgr.ValidatePluginConfig(ctx, id, *opts.config); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + if err := mgr.UpdatePluginConfig(ctx, id, *opts.config); err != nil { + return err + } + } + if opts.users != nil || opts.allUsers != nil { + users, allUsers := cur.Users, cur.AllUsers + if opts.users != nil { + parsed, err := usersToJSON(*opts.users) + if err != nil { + return err + } + users = parsed + allUsers = false // an explicit list means "restrict to these users" + } + if opts.allUsers != nil { + allUsers = *opts.allUsers + } + if err := mgr.UpdatePluginUsers(ctx, id, users, allUsers); err != nil { + return err + } + } + if opts.libraries != nil || opts.allLibraries != nil || opts.writeAccess != nil { + libs, allLibs, writeAccess := cur.Libraries, cur.AllLibraries, cur.AllowWriteAccess + if opts.libraries != nil { + parsed, err := librariesToJSON(*opts.libraries) + if err != nil { + return err + } + libs = parsed + allLibs = false // an explicit list means "restrict to these libraries" + } + if opts.allLibraries != nil { + allLibs = *opts.allLibraries + } + if opts.writeAccess != nil { + writeAccess = *opts.writeAccess + } + if err := mgr.UpdatePluginLibraries(ctx, id, libs, allLibs, writeAccess); err != nil { + return err + } + } + return nil +} + +// usersToJSON accepts either a JSON array (starts with '[') or a comma-separated +// list and returns the JSON-array form the manager stores. +func usersToJSON(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "[]", nil + } + if strings.HasPrefix(strings.TrimSpace(value), "[") { + if !json.Valid([]byte(value)) { + return "", fmt.Errorf("invalid JSON in --users") + } + return value, nil + } + var names []string + for _, u := range strings.Split(value, ",") { + if u = strings.TrimSpace(u); u != "" { + names = append(names, u) + } + } + b, _ := json.Marshal(names) + return string(b), nil +} + +// librariesToJSON accepts either a JSON array (starts with '[') or a +// comma-separated list of integer IDs and returns the JSON-array form stored. +func librariesToJSON(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "[]", nil + } + if strings.HasPrefix(strings.TrimSpace(value), "[") { + if !json.Valid([]byte(value)) { + return "", fmt.Errorf("invalid JSON in --libraries") + } + return value, nil + } + ids := []int{} + for _, l := range strings.Split(value, ",") { + if l = strings.TrimSpace(l); l != "" { + id, err := strconv.Atoi(l) + if err != nil { + return "", fmt.Errorf("invalid library ID %q: must be an integer", l) + } + ids = append(ids, id) + } + } + b, _ := json.Marshal(ids) + return string(b), nil +} + +var pluginRescanCmd = &cobra.Command{ + Use: "rescan", + Short: "Re-discover plugins in the plugins folder", + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + _, ctx := getAdminContext(cmd.Context()) + mgr := GetPluginManager(ctx) + if err := rescanPlugins(ctx, mgr); err != nil { + log.Fatal(ctx, "Failed to rescan plugins", err) + } + }, +} + +func rescanPlugins(ctx context.Context, mgr pluginManager) error { + return mgr.RescanPlugins(ctx) +} diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go new file mode 100644 index 000000000..b6233057f --- /dev/null +++ b/cmd/plugin_test.go @@ -0,0 +1,360 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var samplePlugins = model.Plugins{ + {ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}, + {ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"}, +} + +var _ = Describe("plugin command format flags", func() { + // Regression: list and info must not share a format variable. Binding the + // same var on both commands makes the last init() registration clobber the + // other's default, breaking `plugin list` with no -f flag. + It("defaults `list -f` to table", func() { + Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table")) + }) + + It("defaults `info -f` to text", func() { + Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text")) + }) +}) + +var _ = Describe("formatPluginList", func() { + It("renders csv with a header and one row per plugin", func() { + out, err := formatPluginList(samplePlugins, "csv") + Expect(err).ToNot(HaveOccurred()) + lines := strings.Split(strings.TrimSpace(out), "\n") + Expect(lines).To(HaveLen(3)) // header + 2 rows + Expect(lines[0]).To(ContainSubstring("id")) + Expect(out).To(ContainSubstring("alpha")) + Expect(out).To(ContainSubstring("beta")) + }) + + It("renders valid json", func() { + out, err := formatPluginList(samplePlugins, "json") + Expect(err).ToNot(HaveOccurred()) + var got []map[string]any + Expect(json.Unmarshal([]byte(out), &got)).To(Succeed()) + Expect(got).To(HaveLen(2)) + }) + + It("renders a human table by default", func() { + out, err := formatPluginList(samplePlugins, "table") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("Alpha")) + Expect(out).To(ContainSubstring("1.0.0")) + }) + + It("errors on an unknown format", func() { + _, err := formatPluginList(samplePlugins, "yaml") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("enable/disable plugin", func() { + It("calls EnablePlugin on the manager", func() { + mgr := &tests.MockPluginManager{} + err := enablePlugin(context.Background(), mgr, "alpha") + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"})) + }) + + It("calls DisablePlugin on the manager", func() { + mgr := &tests.MockPluginManager{} + err := disablePlugin(context.Background(), mgr, "beta") + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"})) + }) +}) + +var _ = Describe("applyPluginEdit", func() { + var cur *model.Plugin + BeforeEach(func() { + cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true} + }) + + It("validates then updates config when config is provided", func() { + mgr := &tests.MockPluginManager{} + cfg := `{"key":"val"}` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) + Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) + Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) + }) + + It("updates users with allUsers flag", func() { + mgr := &tests.MockPluginManager{} + all := true + err := applyPluginEdit(context.Background(), mgr, cur, + pluginEditOptions{allUsers: &all}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue()) + }) + + It("updates libraries with allLibraries and write access", func() { + mgr := &tests.MockPluginManager{} + all := true + wr := true + err := applyPluginEdit(context.Background(), mgr, cur, + pluginEditOptions{allLibraries: &all, writeAccess: &wr}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue()) + Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue()) + }) + + It("preserves existing fields when only the write-access flag changes", func() { + mgr := &tests.MockPluginManager{} + no := false + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped + Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse()) + }) + + It("preserves existing users when only the all-users flag changes", func() { + mgr := &tests.MockPluginManager{} + all := true + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped + }) + + It("parses a comma-separated users value into a JSON array", func() { + mgr := &tests.MockPluginManager{} + users := "alice, bob" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) + }) + + It("passes a JSON-array users value through unchanged", func() { + mgr := &tests.MockPluginManager{} + users := `["alice","bob"]` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) + }) + + It("rejects a malformed JSON-array users value", func() { + mgr := &tests.MockPluginManager{} + users := `["alice"` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).To(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty()) + }) + + It("parses a comma-separated libraries value into a JSON array of ints", func() { + mgr := &tests.MockPluginManager{} + libs := "1, 2" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) + }) + + It("rejects a non-integer library ID", func() { + mgr := &tests.MockPluginManager{} + libs := "1,abc" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) + Expect(err).To(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty()) + }) + + It("clears allUsers when an explicit users list is set", func() { + mgr := &tests.MockPluginManager{} + cur.AllUsers = true + users := "alice" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`)) + Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse()) + }) + + It("clears allLibraries when an explicit libraries list is set", func() { + mgr := &tests.MockPluginManager{} + cur.AllLibraries = true + libs := "1" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`)) + Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse()) + }) + + It("does nothing and errors when no fields are set", func() { + mgr := &tests.MockPluginManager{} + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{}) + Expect(err).To(HaveOccurred()) + }) + + It("aborts the config update when validation fails", func() { + mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")} + cfg := `{"key":"val"}` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) + Expect(err).To(HaveOccurred()) + Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty()) + }) +}) + +var _ = Describe("isPackagePath", func() { + It("is true for any .ndp path", func() { + Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue()) + }) + It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() { + Expect(isPackagePath("/nope/x.ndp")).To(BeTrue()) + }) + It("is false for a bare plugin id", func() { + Expect(isPackagePath("my-plugin")).To(BeFalse()) + }) +}) + +var _ = Describe("formatPluginInfo", func() { + It("renders installed plugin details as text", func() { + p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true} + out, err := formatPluginInfo(p, "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("alpha")) + Expect(out).To(ContainSubstring("Alpha")) + }) + It("renders json", func() { + p := &model.Plugin{ID: "alpha", Manifest: `{}`} + out, err := formatPluginInfo(p, "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("alpha")) + }) +}) + +var _ = Describe("formatManifestInfo", func() { + It("renders text with name, version, author", func() { + m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("My Plugin")) + Expect(out).To(ContainSubstring("2.0.0")) + Expect(out).To(ContainSubstring("me")) + }) + + It("omits Description and Website when nil", func() { + m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).ToNot(ContainSubstring("Description:")) + Expect(out).ToNot(ContainSubstring("Website:")) + }) + + It("includes Description and Website when set", func() { + desc := "a cool plugin" + site := "https://example.com" + m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("a cool plugin")) + Expect(out).To(ContainSubstring("https://example.com")) + }) + + It("renders valid json", func() { + m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} + out, err := formatManifestInfo(m, "abc123", "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("\"name\"")) + }) +}) + +var _ = Describe("formatPluginInfo enriched text", func() { + var fixedTime time.Time + + BeforeEach(func() { + fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) + }) + + It("includes Users, Libraries, Permissions, Created, Updated in text output", func() { + p := &model.Plugin{ + ID: "myplugin", + Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`, + Enabled: true, + Users: "alice,bob", + Libraries: "1,2", + CreatedAt: fixedTime, + UpdatedAt: fixedTime.Add(time.Hour), + } + out, err := formatPluginInfo(p, "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("alice,bob")) + Expect(out).To(ContainSubstring("1,2")) + Expect(out).To(ContainSubstring("subsonicapi")) + Expect(out).To(ContainSubstring("users")) + Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z")) + }) + + It("omits Users and Libraries lines when empty", func() { + p := &model.Plugin{ + ID: "myplugin", + Manifest: `{"name":"X","version":"1.0.0","author":"me"}`, + CreatedAt: fixedTime, + UpdatedAt: fixedTime, + } + out, err := formatPluginInfo(p, "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).ToNot(ContainSubstring("Users:")) + Expect(out).ToNot(ContainSubstring("Libraries:")) + }) + + It("does not alter json output", func() { + p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"} + out, err := formatPluginInfo(p, "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring(`"x"`)) + }) +}) + +var _ = Describe("formatManifestInfo enriched text", func() { + It("includes Permissions when declared", func() { + p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} + m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("http")) + Expect(out).To(ContainSubstring("Permissions:")) + }) + + It("omits Permissions line when no permissions declared", func() { + m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).ToNot(ContainSubstring("Permissions:")) + }) + + It("does not alter json output", func() { + p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} + m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} + out, err := formatManifestInfo(m, "abc123", "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring(`"name"`)) + }) +}) + +var _ = Describe("rescanPlugins", func() { + It("calls RescanPlugins on the manager", func() { + mgr := &tests.MockPluginManager{} + err := rescanPlugins(context.Background(), mgr) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.RescanPluginsCalls).To(Equal(1)) + }) +}) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 0939eef4d..d6ffc44d4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -109,7 +109,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) - lyricsLyrics := lyrics.NewLyrics(manager) + lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) diff --git a/conf/configuration.go b/conf/configuration.go index 08f12fc94..665a7992f 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -162,6 +162,7 @@ type scannerOptions struct { GenreSeparators string // Deprecated: Use Tags.genre.Split instead GroupAlbumReleases bool // Deprecated: Use PID.Album instead FollowSymlinks bool // Whether to follow symlinks when scanning directories + IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning PurgeMissing string // Values: "never", "always", "full" } @@ -776,7 +777,7 @@ func setViperDefaults() { viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") - viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") + viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) viper.SetDefault("enablestarrating", true) @@ -821,6 +822,7 @@ func setViperDefaults() { viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.followsymlinks", true) + viper.SetDefault("scanner.ignoredotfolders", true) viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.appendalbumversion", true) diff --git a/consts/consts.go b/consts/consts.go index 4baf4610d..3795b590a 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -156,25 +156,25 @@ var ( Name: "mp3 audio", TargetFormat: "mp3", DefaultBitRate: 192, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", }, { Name: "opus audio", TargetFormat: "opus", DefaultBitRate: 128, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", }, { Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, { Name: "flac audio", TargetFormat: "flac", DefaultBitRate: 0, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", }, } ) diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 19df91d02..d5f4a6580 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/model" ) @@ -33,15 +34,22 @@ type ExternalImage struct { } type Song struct { - ID string - Name string - MBID string - ISRC string - Artist string - ArtistMBID string - Album string - AlbumMBID string - Duration uint32 // Duration in milliseconds, 0 means unknown + ID string + Name string + MBID string + ISRC string + Artists []Artist + Album string + AlbumMBID string + Duration uint32 // Duration in milliseconds, 0 means unknown +} + +// Equals reports strict whole-value equality, used to dedup identical input songs. It hashes +// rather than comparing with ==, which the Artists slice makes illegal. +func (s Song) Equals(other Song) bool { + h1, _ := hashstructure.Hash(s, nil) + h2, _ := hashstructure.Hash(other, nil) + return h1 == h2 } var ( diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go new file mode 100644 index 000000000..c13710a38 --- /dev/null +++ b/core/agents/interfaces_test.go @@ -0,0 +1,27 @@ +package agents + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Song.Equals", func() { + base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} + It("true for identical songs incl Artists", func() { + Expect(base.Equals(base)).To(BeTrue()) + }) + It("false when Artists differ", func() { + other := base + other.Artists = []Artist{{ID: "y", Name: "B"}} + Expect(base.Equals(other)).To(BeFalse()) + }) + It("false when a scalar differs", func() { + other := base + other.Name = "T" + Expect(base.Equals(other)).To(BeFalse()) + }) + It("true when both have empty Artists and equal scalars", func() { + a := Song{ID: "1", Name: "S"} + Expect(a.Equals(a)).To(BeTrue()) + }) +}) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 08f42f130..cd16cbada 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -132,6 +132,22 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader return resizeStaticImage(data, a.size, a.square) } +// toFastScaleType converts images whose concrete type has no optimized scaler +// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed +// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale +// falls back to a generic per-pixel At()/RGBA() loop that is several times +// slower. Fast-path types are returned unchanged. +func toFastScaleType(img image.Image) image.Image { + switch img.(type) { + case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: + return img + default: + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) + return rgba + } +} + func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { original, format, err := image.Decode(bytes.NewReader(data)) if err != nil { @@ -169,6 +185,7 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) dstRect = dst.Bounds() } + original = toFastScaleType(original) xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) buf := bufPool.Get().(*bytes.Buffer) diff --git a/core/external/provider.go b/core/external/provider.go index 74dab4972..459e8a205 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -471,13 +471,19 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err) } - // Enrich songs with artist info if not already present (for top songs, we know the artist) + // Enrich top songs with the queried artist. A song with no artists, or whose first credit the + // agent left unnamed, is attributed to the queried artist. A first credit that already names an + // artist is left as-is: it may be a different (e.g. featured) artist, so stamping the queried + // MBID onto it would create a false name+MBID pairing. for i := range songs { - if songs[i].Artist == "" { - songs[i].Artist = artistName - } - if songs[i].ArtistMBID == "" { - songs[i].ArtistMBID = artist.MbzArtistID + switch { + case len(songs[i].Artists) == 0: + songs[i].Artists = []agents.Artist{{Name: artistName, MBID: artist.MbzArtistID}} + case songs[i].Artists[0].Name == "": + songs[i].Artists[0].Name = artistName + if songs[i].Artists[0].MBID == "" { + songs[i].Artists[0].MBID = artist.MbzArtistID + } } } diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index c9a1a64ef..563003f83 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -3,6 +3,7 @@ package external_test import ( "context" "errors" + "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/agents" @@ -56,7 +57,14 @@ var _ = Describe("Provider - SimilarSongs", func() { Context("when ID is a MediaFile (track)", func() { It("calls GetSimilarSongsByTrack and returns matched songs", func() { track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"} - matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"} + + // Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping. + dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"} + dmParticipant := model.Participant{Artist: dmArtist} + matchedSong := model.MediaFile{ + ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}}, + } // GetEntityByID tries Artist, Album, Playlist, then MediaFile artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() @@ -65,16 +73,19 @@ var _ = Describe("Provider - SimilarSongs", func() { agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5). Return([]agents.Song{ - {Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"}, + {Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}}, }, nil).Once() - // Mock loadTracksByID - no ID matches + // Matcher artist resolution: resolve Depeche Mode in the artist table. + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe() + + // ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it. mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Eq) return ok - })).Return(model.MediaFiles{}, nil).Once() + })).Return(model.MediaFiles{}, nil).Maybe() - // Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called) + // MBID phase: won't fire (empty MBID). mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) if !ok || len(and) < 1 { @@ -88,18 +99,19 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasMBID })).Return(model.MediaFiles{}, nil).Maybe() - // Mock loadTracksByTitleAndArtist - queries by artist name + // Matcher track-fetch: subquery returns the matched song with participants. mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 2 { + if !ok { return false } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false + for _, f := range and { + sql, _, err := f.ToSql() + if err == nil && strings.Contains(sql, "media_file_artists") { + return true + } } - _, hasArtist := eq["order_artist_name"] - return hasArtist + return false })).Return(model.MediaFiles{matchedSong}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "track-1", 5) @@ -165,7 +177,7 @@ var _ = Describe("Provider - SimilarSongs", func() { agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5). Return([]agents.Song{ - {Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"}, + {Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() // Mock loadTracksByID - no ID matches @@ -242,7 +254,7 @@ var _ = Describe("Provider - SimilarSongs", func() { artistRepo.On("Get", "artist-1").Return(&artist, nil).Once() agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5). Return([]agents.Song{ - {Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"}, + {Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() // Mock loadTracksByID - no ID matches diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 0d9b5800d..86f9110e2 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -76,6 +76,63 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) + It("backfills name and MBID onto an unnamed primary credit (the queried artist) and matches", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) + + // Agent leaves the first credit unnamed (e.g. an MBID-less collaborator slot). That blank + // credit IS the queried artist, so enrichment fills both name and MBID; the song then matches + // the queried artist's track via the backfilled identity. + agentSongs := []agents.Song{ + {Name: "Song One", Artists: []agents.Artist{{}}}, + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() + + track := model.MediaFile{ + ID: "song-1", Title: "Song One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}}, + }}, + } + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) + + songs, err := p.TopSongs(ctx, "Artist One", 1) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + }) + + It("does not stamp the queried MBID onto an already-named different first credit", func() { + // The queried artist (One) appears only as a featured collaborator; the displayed first credit + // is a DIFFERENT artist (Two) returned without an MBID. Enrichment must NOT assign One's MBID + // to Two — only Two's name match (which fails here) or One's own credit may resolve the track. + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) + + agentSongs := []agents.Song{ + {Name: "Collab Song", Artists: []agents.Artist{{Name: "Artist Two"}}}, + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() + + // Library track is credited to Artist One (the queried artist) under a same title. If the + // queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID + // could mis-resolve. With the guard, "Artist Two" stays MBID-less and does not match One's track. + track := model.MediaFile{ + ID: "one-track", Title: "Collab Song", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}}, + }}, + } + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) + + songs, err := p.TopSongs(ctx, "Artist One", 1) + + Expect(err).ToNot(HaveOccurred()) + // "Artist Two" (named, MBID-less, not in the library) does not resolve to One's track. + Expect(songs).To(BeEmpty()) + }) + It("returns nil for an unknown artist", func() { // Mock artist not found artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once() @@ -148,6 +205,8 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + // Matcher artist resolution for the title-match path (song2 falls through). + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response agentSongs := []agents.Song{ @@ -159,7 +218,7 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding matching tracks (only find song 1 on bulk query) song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2 + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -195,6 +254,8 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + // Matcher artist resolution for both title-fallback songs. + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response with songs that have NO MBID (empty string) agentSongs := []agents.Song{ @@ -203,10 +264,16 @@ var _ = Describe("Provider - TopSongs", func() { } ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - // Since there are no MBIDs, loadTracksByMBID should not make any database call - // loadTracksByTitle should make a database call for title matching - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + // Title track-fetch: tracks must carry RoleArtist participants so back-mapping routes them. + participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} + song1 := model.MediaFile{ + ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, + } + song2 := model.MediaFile{ + ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, + } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -224,6 +291,8 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + // Matcher artist resolution for song2's title-fallback path. + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response with mixed MBID availability agentSongs := []agents.Song{ @@ -236,8 +305,12 @@ var _ = Describe("Provider - TopSongs", func() { song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - // Mock the title fallback query (finds song2 by title) - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + // Title track-fetch: song2 must carry RoleArtist participants for back-mapping. + participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} + song2 := model.MediaFile{ + ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, + } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 58e9fd152..3d4cd0e72 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -403,6 +403,14 @@ func buildDynamicArgs(opts TranscodeOptions) []string { args = append(args, "-i", opts.FilePath) args = append(args, "-map", "0:a:0") + // Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC); + // -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG). + // Both are needed because the two source families store tags at different + // levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids + // pulling metadata from an embedded cover-art/video stream at index 0. Note: + // adts (AAC) output cannot hold tags, so these are a no-op there. + args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0") + if codec, ok := formatCodecMap[opts.Format]; ok { args = append(args, "-c:a", codec) } diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 2e2895738..9c20e6c05 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() { Describe("isDefaultCommand", func() { It("returns true for known default mp3 command", func() { - Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) }) It("returns true for known default opus command", func() { - Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) }) It("returns true for known default aac command", func() { - Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) + Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) }) It("returns true for known default flac command", func() { - Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) }) It("returns false for a custom command", func() { Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) @@ -113,6 +113,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libmp3lame", "-b:a", "256k", "-ar", "48000", @@ -132,6 +133,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.dsf", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "flac", "-ar", "48000", "-v", "0", @@ -149,6 +151,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libopus", "-b:a", "128k", "-v", "0", @@ -169,6 +172,7 @@ var _ = Describe("ffmpeg", func() { "-ss", "30", "-i", "/music/file.mp3", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libmp3lame", "-b:a", "192k", "-v", "0", @@ -186,6 +190,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "aac", "-b:a", "256k", "-v", "0", @@ -203,6 +208,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.dsf", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "flac", "-sample_fmt", "s32", "-v", "0", diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index 758053042..b9fb8cb74 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -4,56 +4,122 @@ import ( "context" "strings" + . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" ) -// Lyrics can fetch lyrics for a media file. -type Lyrics interface { +// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy +// artist/title lookup, so source-priority resolution can still reach older +// matches without turning it into an unbounded table scan. +const maxLegacyLyricsCandidates = 10 + +// Provider fetches lyrics for a single media file. It is the contract +// implemented by individual lyrics sources, such as plugins. +type Provider interface { GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) } +// Lyrics resolves lyrics for media files, honoring the configured source +// priority. +type Lyrics interface { + Provider + GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) +} + // PluginLoader discovers and loads lyrics provider plugins. type PluginLoader interface { - LoadLyricsProvider(name string) (Lyrics, bool) + LoadLyricsProvider(name string) (Provider, bool) } type lyricsService struct { + ds model.DataStore pluginLoader PluginLoader } // NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin // system is available. -func NewLyrics(pluginLoader PluginLoader) Lyrics { - return &lyricsService{pluginLoader: pluginLoader} +func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics { + return &lyricsService{ds: ds, pluginLoader: pluginLoader} } // GetLyrics returns lyrics for the given media file, trying sources in the // order specified by conf.Server.LyricsPriority. func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - var lyricsList model.LyricList - var err error + return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf}) +} +// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup, +// scanning a bounded window of duplicate matches so source priority still wins +// across them. +func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) { + opts := songsByArtistTitleWithLyricsFirst(artist, title) + opts.Max = maxLegacyLyricsCandidates + mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts) + if err != nil { + return nil, err + } + if len(mediaFiles) == 0 { + return nil, nil + } + candidates := make([]*model.MediaFile, 0, len(mediaFiles)) + for i := range mediaFiles { + candidates = append(candidates, &mediaFiles[i]) + } + return l.getLyricsForCandidates(ctx, candidates) +} + +func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions { + return model.QueryOptions{ + Sort: "lyrics, updated_at", + Order: "desc", + Filters: And{ + Eq{"missing": false}, + Eq{"title": title}, + Or{ + persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), + }, + }, + } +} + +func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) { for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { pattern = strings.TrimSpace(pattern) - switch { - case strings.EqualFold(pattern, "embedded"): - lyricsList, err = fromEmbedded(ctx, mf) - case strings.HasPrefix(pattern, "."): - lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern)) - default: - lyricsList, err = l.fromPlugin(ctx, mf, pattern) + if pattern == "" { + continue } - if err != nil { - log.Error(ctx, "error getting lyrics", "source", pattern, err) - } + for _, mf := range mediaFiles { + if mf == nil { + continue + } - if len(lyricsList) > 0 { - return lyricsList, nil + lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern) + if err != nil { + log.Error(ctx, "error getting lyrics", "source", pattern, err) + continue + } + + if len(lyricsList) > 0 { + return lyricsList, nil + } } } return nil, nil } + +func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) { + switch { + case strings.EqualFold(pattern, "embedded"): + return fromEmbedded(ctx, mf) + case strings.HasPrefix(pattern, "."): + return fromExternalFile(ctx, mf, pattern) + default: + return l.fromPlugin(ctx, mf, pattern) + } +} diff --git a/core/lyrics/lyrics_suite_test.go b/core/lyrics/lyrics_suite_test.go index f87381905..c9fdcbae8 100644 --- a/core/lyrics/lyrics_suite_test.go +++ b/core/lyrics/lyrics_suite_test.go @@ -1,9 +1,13 @@ package lyrics_test import ( + "io/fs" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +19,17 @@ func TestLyrics(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Lyrics Suite") } + +// core/storage/local calls log.Fatal if the default scanner extractor is unregistered +// when constructing any localStorage. Register a no-op so storage.For("file://...") works +// in tests without importing the real extractor. +var _ = BeforeSuite(func() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return &noopExtractor{} + }) +}) + +type noopExtractor struct{} + +func (e *noopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } +func (e *noopExtractor) Version() string { return "noop" } diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 9ab732ad1..6baacbe71 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -16,13 +17,14 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("sources", func() { +var _ = Describe("Lyrics", func() { var mf model.MediaFile var ctx context.Context const badLyrics = "This is a set of lyrics\nThat is not good" - unsynced, _ := model.ToLyrics("xxx", badLyrics) - embeddedLyrics := model.LyricList{*unsynced} + unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(badLyrics)) + unsynced, _ := unsyncedList.Main() + embeddedLyrics := model.LyricList{unsynced} syncedLyrics := model.LyricList{ model.Lyrics{ @@ -44,6 +46,71 @@ var _ = Describe("sources", func() { }, } + elrcLyrics := model.LyricList{ + model.Lyrics{ + DisplayArtist: "ELRC Artist", + DisplayTitle: "ELRC Song", + Lang: "eng", + Line: []model.Line{ + { + Start: new(int64(1000)), + End: new(int64(3000)), + Value: "Lead words", + Cue: []model.Cue{ + { + Start: new(int64(1000)), + End: new(int64(1500)), + Value: "Lead ", + ByteStart: 0, + ByteEnd: 4, + }, + { + Start: new(int64(1500)), + End: new(int64(3000)), + Value: "words", + ByteStart: 5, + ByteEnd: 9, + }, + }, + }, + { + Start: new(int64(3000)), + Value: "Fallback line", + }, + }, + Synced: true, + }, + } + + ttmlLyrics := model.LyricList{ + model.Lyrics{ + Kind: "main", + Lang: "eng", + Line: []model.Line{ + { + Start: new(int64(18800)), + Value: "We're no strangers to love", + }, + { + Start: new(int64(22800)), + Value: "You know the rules and so do I", + }, + }, + Synced: true, + }, + model.Lyrics{ + Kind: "main", + Lang: "por", + Line: []model.Line{ + { + Start: new(int64(18800)), + Value: "Nao somos estranhos ao amor", + }, + }, + Synced: true, + }, + } + unsyncedLyrics := model.LyricList{ model.Lyrics{ Lang: "xxx", @@ -59,6 +126,25 @@ var _ = Describe("sources", func() { }, } + srtLyrics := model.LyricList{ + model.Lyrics{ + Lang: "xxx", + Line: []model.Line{ + { + Start: new(int64(18800)), + End: new(int64(22800)), + Value: "We're from subtitles", + }, + { + Start: new(int64(22801)), + End: new(int64(26000)), + Value: "Another subtitle line", + }, + }, + Synced: true, + }, + } + BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -68,19 +154,104 @@ var _ = Describe("sources", func() { Lyrics: string(lyricsJson), Path: "tests/fixtures/test.mp3", } - ctx = context.Background() + ctx = GinkgoT().Context() }) DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { conf.Server.LyricsPriority = priority - svc := lyrics.NewLyrics(nil) + svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(expected)) }, Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics), Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics), - Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics)) + Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics), + Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics), + Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics), + Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics)) + + It("resolves source priority across duplicate media files", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + embeddedJSON, err := json.Marshal(embeddedLyrics) + Expect(err).To(BeNil()) + + repo := &tests.MockMediaFileRepo{} + repo.SetData(model.MediaFiles{ + { + Lyrics: string(embeddedJSON), + Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3", + }, + { + Lyrics: "[]", + Path: "tests/fixtures/test.mp3", + }, + }) + svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil) + + list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).To(BeNil()) + Expect(list).To(Equal(ttmlLyrics)) + }) + + It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() { + dir, err := os.MkdirTemp("", "lyrics-case-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + Expect(os.RemoveAll(dir)).To(Succeed()) + }) + + probe := filepath.Join(dir, "CASECHECK") + Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed()) + _, err = os.Stat(filepath.Join(dir, "casecheck")) + if err == nil { + Skip("filesystem is case-insensitive") + } + Expect(os.IsNotExist(err)).To(BeTrue()) + + conf.Server.LyricsPriority = ".LRC" + Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed()) + + svc := lyrics.NewLyrics(nil, nil) + list, err := svc.GetLyrics(ctx, &model.MediaFile{ + LibraryPath: dir, + Path: "song.mp3", + }) + + Expect(err).To(BeNil()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(Equal([]model.Line{ + {Start: new(int64(1000)), Value: "Upper suffix"}, + })) + }) + + It("returns a non-Lyricsfile YAML sidecar as plain text, shadowing lower-priority sources", func() { + dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + Expect(os.RemoveAll(dir)).To(Succeed()) + }) + + Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed()) + + conf.Server.LyricsPriority = ".yaml,.lrc" + svc := lyrics.NewLyrics(nil, nil) + list, err := svc.GetLyrics(ctx, &model.MediaFile{ + LibraryPath: dir, + Path: "song.mp3", + }) + + // ParseLyrics falls back to plain text for any suffix when the content + // doesn't match the structured format, so the .yaml hit is non-empty and + // shadows the lower-priority .lrc entirely. + Expect(err).To(BeNil()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).To(Equal([]model.Line{ + {Value: "title: not lyricsfile"}, + })) + }) Context("Errors", func() { var RegularUserContext = XContext @@ -110,7 +281,7 @@ var _ = Describe("sources", func() { It("should fallback to embedded if an error happens when parsing file", func() { conf.Server.LyricsPriority = ".mp3,embedded" - svc := lyrics.NewLyrics(nil) + svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) @@ -119,7 +290,7 @@ var _ = Describe("sources", func() { It("should return nothing if error happens when trying to parse file", func() { conf.Server.LyricsPriority = ".mp3" - svc := lyrics.NewLyrics(nil) + svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(BeEmpty()) @@ -137,7 +308,7 @@ var _ = Describe("sources", func() { It("should return lyrics from a plugin", func() { conf.Server.LyricsPriority = "test-lyrics-plugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -147,7 +318,7 @@ var _ = Describe("sources", func() { conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" mf.Lyrics = "" // No embedded lyrics mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -156,7 +327,7 @@ var _ = Describe("sources", func() { It("should skip plugin if embedded has lyrics", func() { conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // embedded wins @@ -165,7 +336,7 @@ var _ = Describe("sources", func() { It("should skip unknown plugin names gracefully", func() { conf.Server.LyricsPriority = "nonexistent-plugin,embedded" mockLoader.notFound = true - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded @@ -175,7 +346,7 @@ var _ = Describe("sources", func() { conf.Server.LyricsPriority = "MyLyricsPlugin" mockLoader.pluginName = "MyLyricsPlugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -184,12 +355,56 @@ var _ = Describe("sources", func() { It("should handle plugin error gracefully", func() { conf.Server.LyricsPriority = "test-lyrics-plugin,embedded" mockLoader.err = fmt.Errorf("plugin error") - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded }) }) + + var _ = Describe("GetLyricsByArtistTitle", func() { + var svc lyrics.Lyrics + var repo *tests.MockMediaFileRepo + var ds *tests.MockDataStore + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.LyricsPriority = "embedded" + repo = &tests.MockMediaFileRepo{} + ds = &tests.MockDataStore{MockedMediaFile: repo} + svc = lyrics.NewLyrics(ds, nil) + }) + + It("bounds the query to a duplicate window", func() { + repo.SetData(model.MediaFiles{}) + _, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).ToNot(HaveOccurred()) + Expect(repo.Options.Max).To(Equal(10)) + }) + + It("returns nil when no media file matches", func() { + repo.SetData(model.MediaFiles{}) + list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song") + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(BeNil()) + }) + + It("resolves lyrics from the matched media files", func() { + embeddedList, err := model.ParseLyrics(".lrc", "eng", []byte("Embedded lyrics line")) + Expect(err).ToNot(HaveOccurred()) + embedded, _ := embeddedList.Main() + embeddedJSON, err := json.Marshal(model.LyricList{embedded}) + Expect(err).ToNot(HaveOccurred()) + repo.SetData(model.MediaFiles{ + {ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)}, + }) + + list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line")) + }) + }) }) type mockPluginLoader struct { @@ -206,7 +421,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string { return []string{"test-lyrics-plugin"} } -func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { +func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) { if m.notFound { return nil, false } diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 82a10ca41..9de2f6a18 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -3,9 +3,12 @@ package lyrics import ( "context" "errors" - "os" + "fmt" + "io" + "io/fs" "path" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/ioutils" @@ -23,31 +26,45 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er } func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) { - basePath := mf.AbsolutePath() - ext := path.Ext(basePath) + ext := path.Ext(mf.Path) + sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix - externalLyric := basePath[0:len(basePath)-len(ext)] + suffix + store, err := storage.For(mf.LibraryPath) + if err != nil { + return nil, fmt.Errorf("getting storage for library: %w", err) + } + fsys, err := store.FS() + if err != nil { + return nil, fmt.Errorf("opening library filesystem: %w", err) + } - contents, err := ioutils.UTF8ReadFile(externalLyric) - if errors.Is(err, os.ErrNotExist) { - log.Trace(ctx, "no lyrics found at path", "path", externalLyric) + f, err := fsys.Open(sidecarRelPath) + if errors.Is(err, fs.ErrNotExist) { + log.Trace(ctx, "no lyrics found at path", "path", sidecarRelPath) return nil, nil } else if err != nil { return nil, err } + defer f.Close() - lyrics, err := model.ToLyrics("xxx", string(contents)) + contents, err := io.ReadAll(ioutils.UTF8Reader(f)) if err != nil { - log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err) return nil, err - } else if lyrics == nil { - log.Trace(ctx, "empty lyrics from external file", "path", externalLyric) + } + + list, err := model.ParseLyrics(suffix, "xxx", contents) + if err != nil { + log.Error(ctx, "error parsing external lyric file", "path", sidecarRelPath, err) + return nil, err + } + + if len(list) == 0 { + log.Trace(ctx, "empty lyrics from external file", "path", sidecarRelPath) return nil, nil } - log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric) - - return model.LyricList{*lyrics}, nil + log.Trace(ctx, "retrieved lyrics from external file", "path", sidecarRelPath) + return list, nil } // fromPlugin attempts to load lyrics from a plugin with the given name. diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index d1aefcb5d..68f45424e 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -3,6 +3,7 @@ package lyrics import ( "context" "encoding/json" + "path/filepath" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -25,10 +26,12 @@ var _ = Describe("sources", func() { const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - synced, _ := model.ToLyrics("eng", syncedLyrics) - unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) + syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics)) + unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics)) + synced, _ := syncedList.Main() + unsynced, _ := unsyncedList.Main() - expectedList := model.LyricList{*synced, *unsynced} + expectedList := model.LyricList{synced, unsynced} lyricsJson, err := json.Marshal(expectedList) Expect(err).ToNot(HaveOccurred()) @@ -53,72 +56,51 @@ var _ = Describe("sources", func() { }) Describe("fromExternalFile", func() { + var fixturesDir string + + BeforeEach(func() { + // tests.Init sets CWD to the repo root, so "tests/fixtures" resolves correctly. + abs, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + fixturesDir = abs + }) + + mf := func(name string) *model.MediaFile { + return &model.MediaFile{LibraryPath: fixturesDir, Path: name} + } + It("should return nil for lyrics that don't exist", func() { - mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc") Expect(err).To(BeNil()) Expect(lyrics).To(HaveLen(0)) }) - It("should return synchronized lyrics from a file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + // fromExternalFile delegates format parsing to model.ParseLyrics; the + // per-format parser output is covered exhaustively in the model package. + // Here we only verify each suffix is read from the library FS and routed. + DescribeTable("should read the sidecar file and route its suffix to a parser", + func(name, suffix string, expectSynced bool) { + lyrics, err := fromExternalFile(ctx, mf(name), suffix) - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "We're no strangers to love", - }, - { - Start: new(int64(22801)), - Value: "You know the rules and so do I", - }, - }, - Offset: new(int64(-100)), - Synced: true, - }, - })) - }) - - It("should return unsynchronized lyrics from a file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".txt") - - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - Synced: false, - }, - })) - }) + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeEmpty()) + Expect(lyrics[0].Line).ToNot(BeEmpty()) + Expect(lyrics[0].Synced).To(Equal(expectSynced)) + }, + Entry(".lrc synced", "test.mp3", ".lrc", true), + Entry(".elrc enhanced", "test.mp3", ".elrc", true), + Entry(".txt plain", "test.mp3", ".txt", false), + Entry(".srt subtitles", "test.mp3", ".srt", true), + Entry(".ttml multilingual", "test.mp3", ".ttml", true), + Entry(".yaml lyricsfile", "test.mp3", ".yaml", true), + ) It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { - // The function looks for , so we need to pass - // a MediaFile with .mp3 path and look for .lrc suffix - mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".lrc") Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) - - // The critical assertion: even with BOM, synced should be true Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(1)) Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) @@ -126,14 +108,10 @@ var _ = Describe("sources", func() { }) It("should handle UTF-16 LE encoded LRC files", func() { - mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".lrc") Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) - - // UTF-16 should be properly converted to UTF-8 Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(2)) Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) @@ -141,5 +119,31 @@ var _ = Describe("sources", func() { Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) }) + + It("should handle TTML files with UTF-8 BOM marker", func() { + lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".ttml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Kind).To(Equal("main")) + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[0].Line).To(HaveLen(1)) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) + Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line")) + }) + + It("should handle UTF-16 BE encoded TTML files", func() { + lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".ttml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Kind).To(Equal("main")) + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[0].Line).To(HaveLen(2)) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) + Expect(lyrics[0].Line[0].Value).To(Equal("UTF16 line one")) + Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) + Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two")) + }) }) }) diff --git a/core/matcher/doc.go b/core/matcher/doc.go new file mode 100644 index 000000000..1024bc274 --- /dev/null +++ b/core/matcher/doc.go @@ -0,0 +1,124 @@ +// Package matcher matches song results from external agents (Last.fm, Deezer, +// etc.) to tracks in the local music library, prioritizing accuracy over recall. +// +// It exposes a single [Matcher] type with two entry points that share the same +// matching algorithm: +// +// - [Matcher.MatchSongs] returns an ordered, deduplicated slice of library +// tracks, capped at a requested count. Use it when presenting "similar +// songs" results to a client. +// - [Matcher.MatchSongsIndexed] returns a map from input-song index to matched +// track, with no deduplication. Use it when the caller needs to correlate +// each result back to its input position (e.g. to attach a per-song +// similarity score). +// +// # Algorithm Overview +// +// Each input song is resolved to its best-matching library track using four +// strategies, applied in priority order. A song matched by a higher-priority +// strategy is never reconsidered by a lower-priority one: +// +// 1. Direct ID match: songs with an ID are matched to a MediaFile by ID. +// 2. MusicBrainz Recording ID (MBID) match: songs with an MBID are matched to +// tracks with the same mbz_recording_id. +// 3. ISRC match: songs with an ISRC are matched to tracks carrying that ISRC tag. +// 4. Title+Artist fuzzy match: remaining songs are matched by fuzzy string +// comparison with metadata-specificity scoring (see below). +// +// Priority order is ID > MBID > ISRC > Title+Artist, so more reliable +// identifiers always take precedence over fuzzy text matching. Missing tracks +// (those no longer present on disk) are never matched. +// +// # Fuzzy Matching Details +// +// Title+artist matching uses Jaro-Winkler similarity, with a threshold +// configurable via conf.Server.Matcher.FuzzyThreshold (default 85%). A library +// track must clear the title threshold to be considered. Candidates that clear +// it are ranked by, in order: +// +// 1. Title similarity (Jaro-Winkler score, 0.0–1.0) +// 2. Duration proximity (closer duration scores higher; 1.0 when the agent +// reports no duration) +// 3. Specificity level (0–5, based on metadata precision; higher is better) +// 4. Artist overlap (how many of the song's artists the track credits; more +// shared artists is better) +// 5. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred; +// prioritizes tracks that are starred or rated >= 4, but only among +// candidates of equal specificity and overlap) +// 6. Album similarity (Jaro-Winkler, as the final tiebreaker) +// +// The specificity levels, from most to least specific, are: +// +// Level 5: Title + Artist identity + Album MBID +// Level 4: Title + Artist identity + Album name (fuzzy) +// Level 3: Title + Artist name + Album name (fuzzy) +// Level 2: Title + Artist identity +// Level 1: Title + Artist name +// Level 0: Title only +// +// "Artist identity" is a match on the artist's Navidrome ID (the strongest signal, +// when a source supplies one) or its MBID. A plain name match is the weaker fallback +// used for an artist with no identity match (e.g. a cover credited to a different +// artist of the same name). +// +// The title phase always requires an agent artist to scope the library query, so +// Level 0 does not mean "no artist": it applies when a candidate matches on title +// but its own artist differs from the query's (e.g. a cover or a featured-artist +// credit), leaving the title as the only shared field. +// +// A song may carry several artists, and the title phase scopes candidate tracks by +// ANY of them: a track credited to at least one shared artist is considered. When a +// source supplies a Navidrome artist ID, that artist is matched directly, skipping +// name/MBID resolution. Among equally specific candidates, the one sharing more of +// the song's artists wins, so a track crediting every collaborator outranks one +// crediting only a single artist. +// +// Each input song is scored independently, so two songs with the same title and +// artist but different durations can resolve to different library tracks (each +// matches the track closest to its own duration). +// +// # Examples +// +// All examples below exercise the title+artist phase, where the interesting +// behavior lives. (Identifier phases — ID, MBID, ISRC — are exact lookups that +// always win over fuzzy matching; they need no illustration.) +// +// Title threshold — a near-miss title still matches; an exact-only threshold +// rejects it: +// +// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} +// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} +// With threshold 85%: match succeeds (similarity ~0.87) +// With threshold 100%: no match (not an exact title) +// +// Specificity ranking — among candidates that clear the title threshold, a +// better album match wins: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: +// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"} // Level 1 +// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 +// Result: t2 (Level 3 beats Level 1 on the album match) +// +// Duration tiebreak — with title and artist equal, the closest duration wins, +// so two near-identical input songs can resolve to different tracks: +// +// Agent returns: +// {Name: "Untitled", Artist: "Interpol", Duration: 245000} // 4:05 +// {Name: "Untitled", Artist: "Interpol", Duration: 600000} // 10:00 (a live take) +// Library has: +// {ID: "studio", Title: "Untitled", Artist: "Interpol", Duration: 248} // 4:08 +// {ID: "live", Title: "Untitled", Artist: "Interpol", Duration: 602} // 10:02 +// Result: studio for the first song, live for the second +// +// Preferred track — when conf.Server.Matcher.PreferStarred is enabled, a +// starred (or rating >= 4) track is preferred, but only when specificity and +// artist overlap are equal. A more specific match always wins regardless of the +// preferred flag: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: +// {ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 +// {ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Starred: true} // Level 1, starred +// Result: exact (specificity outranks the starred flag; preferred only breaks ties of equal identity) +package matcher diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 54c2a368e..25b8fda5f 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -3,12 +3,16 @@ package matcher import ( "context" "fmt" + "maps" "math" + "slices" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "github.com/xrash/smetrics" ) @@ -23,176 +27,73 @@ func New(ds model.DataStore) *Matcher { return &Matcher{ds: ds} } -// MatchSongs matches agent song results to local library tracks using a multi-phase -// matching algorithm that prioritizes accuracy over recall. +// MatchSongs matches agent songs to library tracks and returns up to count +// tracks in the input's order. See the package documentation for the matching +// algorithm. // -// # Algorithm Overview -// -// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the -// local music library using four matching strategies in priority order: -// -// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID -// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with -// matching mbz_recording_id -// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag -// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison -// with metadata specificity scoring -// -// # Matching Priority -// -// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist. -// This ensures that more reliable identifiers take precedence over fuzzy text matching. -// -// # Fuzzy Matching Details -// -// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable -// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by: -// -// 1. Title similarity (Jaro-Winkler score, 0.0-1.0) -// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) -// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is -// starred or has rating >= 4) -// 4. Specificity level (0-5, based on metadata precision): -// - Level 5: Title + Artist MBID + Album MBID (most specific) -// - Level 4: Title + Artist MBID + Album name (fuzzy) -// - Level 3: Title + Artist name + Album name (fuzzy) -// - Level 2: Title + Artist MBID -// - Level 1: Title + Artist name -// - Level 0: Title only -// 5. Album similarity (Jaro-Winkler, as final tiebreaker) -// -// # Examples -// -// Example 1 - MBID Priority: -// -// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"} -// Library has: [ -// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"}, -// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, -// ] -// Result: t1 (MBID match takes priority over title+artist) -// -// Example 2 - ISRC Priority: -// -// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"} -// Library has: [ -// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}}, -// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, -// ] -// Result: t1 (ISRC match takes priority over title+artist) -// -// Example 3 - Specificity Ranking: -// -// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} -// Library has: [ -// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1 -// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3 -// ] -// Result: t2 (Level 3 beats Level 1 due to album match) -// -// Example 4 - Fuzzy Title Matching: -// -// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} -// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} -// With threshold=85%: Match succeeds (similarity ~0.87) -// With threshold=100%: No match (not exact) -// -// # Parameters -// -// - ctx: Context for database operations -// - songs: Slice of agent.Song results from external providers -// - count: Maximum number of matches to return -// -// # Returns -// -// Returns up to 'count' MediaFiles from the library that best match the input songs, -// preserving the original order from the agent. Songs that cannot be matched are skipped. +// Each library track appears at most once, unless the same input song is +// repeated: identical input songs intentionally yield repeated output tracks, +// while distinct songs that resolve to the same track are deduplicated. Songs +// that cannot be matched are skipped. func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { if len(songs) == 0 { return nil, nil } - - byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + matches, err := m.resolveMatches(ctx, songs) if err != nil { return nil, err } - return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil + return orderAndDedup(songs, matches, count), nil } -// MatchSongsIndexed matches agent song results to local library tracks and returns a map -// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map. -// This preserves original indices, allowing callers to correlate results back to the input slice. +// MatchSongsIndexed matches agent songs to library tracks and returns a map from +// input-song index to matched track, letting callers correlate results back to +// the input slice. Unmatched songs are omitted from the map. Unlike MatchSongs, +// results are not deduplicated. See the package documentation for the matching +// algorithm. func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { if len(songs) == 0 { return nil, nil } + return m.resolveMatches(ctx, songs) +} - byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) - if err != nil { - return nil, err - } - +// resolveMatches resolves each input song to its best-matching library track, +// keyed by the song's index. Loaders run in priority order (ID > MBID > ISRC > +// Title); each only fills indices not already matched by a higher-priority loader. +func (m *Matcher) resolveMatches(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { result := make(map[int]model.MediaFile, len(songs)) - for i, t := range songs { - if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found { - result[i] = mf + if err := m.matchByID(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by ID: %w", err) + } + if err := m.matchByMBID(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by MBID: %w", err) + } + if err := m.matchByISRC(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by ISRC: %w", err) + } + // The title phase is best-effort: a DB failure there must not discard the exact + // matches already found by the higher-priority phases. Only surface it as fatal + // when nothing matched at all. + if err := m.matchByTitle(ctx, songs, result); err != nil { + if len(result) == 0 { + return nil, fmt.Errorf("failed to match tracks by title: %w", err) } + log.Warn(ctx, "Title matching failed; returning matches from exact phases only", err) } return result, nil } -func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) { - byID, err = m.loadTracksByID(ctx, songs) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err) - } - byMBID, err = m.loadTracksByMBID(ctx, songs, byID) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err) - } - byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) - } - byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err) - } - return byID, byMBID, byISRC, byTitle, nil -} - -// songMatchedIn checks if a song has already been matched in any of the provided match maps. -func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool { - _, found := lookupByIdentifiers(s, priorMatches...) - return found -} - -// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps. -func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) { - keys := []string{s.ID, s.MBID, s.ISRC} - for _, m := range maps { - for _, key := range keys { - if key != "" { - if mf, ok := m[key]; ok && mf.ID != "" { - return mf, true - } - } - } - } - return model.MediaFile{}, false -} - -// loadTracksByID fetches MediaFiles from the library using direct ID matching. -func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { +// matchByID fills result with direct ID matches. +func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var ids []string for _, s := range songs { if s.ID != "" { ids = append(ids, s.ID) } } - matches := map[string]model.MediaFile{} if len(ids) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ @@ -201,27 +102,37 @@ func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[ }, }) if err != nil { - return matches, err + return err } + byID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { - if _, ok := matches[mf.ID]; !ok { - matches[mf.ID] = mf + byID[mf.ID] = mf // media_file.id is unique, so no dedup needed + } + for i, s := range songs { + if s.ID == "" { + continue + } + if mf, ok := byID[s.ID]; ok { + result[i] = mf } } - return matches, nil + return nil } -// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs. -func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// matchByMBID fills result with MusicBrainz Recording ID matches, skipping +// songs already matched by a higher-priority loader. +func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var mbids []string - for _, s := range songs { - if s.MBID != "" && !songMatchedIn(s, priorMatches...) { + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.MBID != "" { mbids = append(mbids, s.MBID) } } - matches := map[string]model.MediaFile{} if len(mbids) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ @@ -230,52 +141,86 @@ func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, pri }, }) if err != nil { - return matches, err + return err } + byMBID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { if id := mf.MbzRecordingID; id != "" { - if _, ok := matches[id]; !ok { - matches[id] = mf + if _, ok := byMBID[id]; !ok { + byMBID[id] = mf } } } - return matches, nil + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.MBID == "" { + continue + } + if mf, ok := byMBID[s.MBID]; ok { + result[i] = mf + } + } + return nil } -// loadTracksByISRC fetches MediaFiles from the library using ISRC matching. -func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// matchByISRC fills result with ISRC tag matches, skipping songs already +// matched by a higher-priority loader. +func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var isrcs []string - for _, s := range songs { - if s.ISRC != "" && !songMatchedIn(s, priorMatches...) { + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.ISRC != "" { isrcs = append(isrcs, s.ISRC) } } - matches := map[string]model.MediaFile{} if len(isrcs) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, Sort: "starred desc, rating desc, year asc, compilation asc", }) if err != nil { - return matches, err + return err } + byISRC := make(map[string]model.MediaFile, len(res)) for _, mf := range res { for _, isrc := range mf.Tags.Values(model.TagISRC) { - if _, ok := matches[isrc]; !ok { - matches[isrc] = mf + if _, ok := byISRC[isrc]; !ok { + byISRC[isrc] = mf } } } - return matches, nil + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.ISRC == "" { + continue + } + if mf, ok := byISRC[s.ISRC]; ok { + result[i] = mf + } + } + return nil +} + +// queryArtist is one of a song's artists. A non-empty id is matched directly, skipping name/MBID +// resolution; name is pre-sanitized (article-stripped). +type queryArtist struct { + id string + name string + mbid string } // songQuery represents a normalized query for matching a song to library tracks. type songQuery struct { title string - artist string - artistMBID string + artists []queryArtist album string albumMBID string durationMs uint32 @@ -286,11 +231,13 @@ type matchScore struct { titleSimilarity float64 durationProximity float64 preferredMatch bool - albumSimilarity float64 specificityLevel int + artistOverlap int + albumSimilarity float64 } // betterThan returns true if this score beats another. +// Identity signals (specificity, overlap) outrank the taste signal (preferred). func (s matchScore) betterThan(other matchScore) bool { if s.titleSimilarity != other.titleSimilarity { return s.titleSimilarity > other.titleSimilarity @@ -298,106 +245,313 @@ func (s matchScore) betterThan(other matchScore) bool { if s.durationProximity != other.durationProximity { return s.durationProximity > other.durationProximity } - if s.preferredMatch != other.preferredMatch { - return s.preferredMatch - } if s.specificityLevel != other.specificityLevel { return s.specificityLevel > other.specificityLevel } + if s.artistOverlap != other.artistOverlap { + return s.artistOverlap > other.artistOverlap + } + if s.preferredMatch != other.preferredMatch { + return s.preferredMatch + } return s.albumSimilarity > other.albumSimilarity } // sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization -// when the same track is scored against multiple queries in the inner loop. The `mf` field -// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist -// sanitized slice. +// when the same track is scored against multiple queries. The `mf` field is a pointer to avoid +// copying the large MediaFile struct into each entry of the sanitized slice. type sanitizedTrack struct { - mf *model.MediaFile - title string - artist string - album string + mf *model.MediaFile + title string + artist string + album string + artistIDs map[string]struct{} // query's owned artist IDs this track credits; an ID match is the strongest identity signal + artistMBIDs map[string]struct{} // MBIDs of those artists (artist table; mf.MbzArtistID is not populated on the bulk path) } -func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { +func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack { return sanitizedTrack{ - mf: mf, - title: str.SanitizeFieldForSorting(mf.Title), - artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), - album: str.SanitizeFieldForSorting(mf.Album), + mf: mf, + title: str.SanitizeFieldForSorting(mf.Title), + artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), + album: str.SanitizeFieldForSorting(mf.Album), + artistIDs: artistIDs, + artistMBIDs: artistMBIDs, } } -// computeSpecificityLevel determines how well query metadata matches a track (0-5). -// The track's title, artist, and album fields must be pre-sanitized. +// computeSpecificityLevel determines how well query metadata matches a track (0-5), taking the best +// level achievable across any of the query's artists. Fields must be pre-sanitized. +// +// A query artist counts as an identity match when the track credits its resolved Navidrome ID (the +// strongest signal, our own primary key) or its MBID; that identity then unlocks the album tiers. +// Name matching is the lowest fallback for an artist with no identity match (e.g. a cover credited +// to a different artist by the same name). func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { - if q.artistMBID != "" && q.albumMBID != "" && - t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { - return 5 - } - if q.artistMBID != "" && q.album != "" && - t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold { - return 4 - } - if q.artist != "" && q.album != "" && - t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold { - return 3 - } - if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID { - return 2 - } - if q.artist != "" && t.artist == q.artist { - return 1 - } - if t.title == q.title { - return 0 - } - return -1 -} - -// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering. -func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { - queries := m.buildTitleQueries(songs, priorMatches...) - if len(queries) == 0 { - return map[string]model.MediaFile{}, nil - } - - threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 - - byArtist := map[string][]songQuery{} - for _, q := range queries { - if q.artist != "" { - byArtist[q.artist] = append(byArtist[q.artist], q) + best := 0 + albumOK := q.album != "" && similarityRatio(t.album, q.album) >= albumThreshold + for _, a := range q.artists { + _, idMember := t.artistIDs[a.id] + _, mbidMember := t.artistMBIDs[a.mbid] + identity := (a.id != "" && idMember) || (a.mbid != "" && mbidMember) + level := 0 + switch { + case identity && q.albumMBID != "" && t.mf.MbzAlbumID == q.albumMBID: + level = 5 + case identity && q.album != "" && albumOK: + level = 4 + case a.name != "" && q.album != "" && t.artist == a.name && albumOK: + level = 3 + case identity: + level = 2 + case a.name != "" && t.artist == a.name: + level = 1 + } + if level > best { + best = level } } + return best +} - matches := map[string]model.MediaFile{} - for artist, artistQueries := range byArtist { - tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"order_artist_name": artist}, - squirrel.Eq{"missing": false}, - }, - Sort: "starred desc, rating desc, year asc, compilation asc", - }) - if err != nil { +// indexedQuery pairs a normalized songQuery with the index of the input song +// it came from, so title matches can be written back to result by index. +type indexedQuery struct { + index int + query songQuery +} + +// matchByTitle fills result with fuzzy title+artist matches, skipping songs +// already matched by a higher-priority loader. +func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { + queries := groupQueries(songs, result) + if len(queries) == 0 { + return nil + } + + resolved, err := m.resolveArtists(ctx, queries) + if err != nil || len(resolved.allIDs) == 0 { + return err + } + + tracks, err := m.fetchTracksCreditedTo(ctx, resolved.allIDs) + if err != nil { + return err + } + + tracksByQuery := resolved.bucketTracks(tracks) + threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 + for _, iq := range queries { + sanitized := tracksByQuery[iq.index] + if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found { + result[iq.index] = mf + } + } + return nil +} + +// groupQueries builds one normalized title query per still-unmatched song, carrying its full +// artist set. An artist is usable if it carries a Navidrome ID or a non-empty sanitized name; +// songs with no usable artist are skipped (the title phase needs at least one to scope the query). +func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexedQuery { + var queries []indexedQuery + for i, s := range songs { + if _, done := result[i]; done { continue } - - sanitized := make([]sanitizedTrack, len(tracks)) - for i := range tracks { - sanitized[i] = newSanitizedTrack(&tracks[i]) + var artists []queryArtist + for _, a := range s.Artists { + name := str.SanitizeFieldForSortingNoArticle(a.Name) + if a.ID == "" && name == "" && a.MBID == "" { + continue + } + artists = append(artists, queryArtist{id: a.ID, name: name, mbid: a.MBID}) } + if len(artists) == 0 { + continue + } + queries = append(queries, indexedQuery{index: i, query: songQuery{ + title: str.SanitizeFieldForSorting(s.Name), + artists: artists, + album: str.SanitizeFieldForSorting(s.Album), + albumMBID: s.AlbumMBID, + durationMs: s.Duration, + }}) + } + return queries +} - for _, q := range artistQueries { - if mf, found := m.findBestMatch(q, sanitized, threshold); found { - key := q.title + "|" + q.artist - if _, exists := matches[key]; !exists { - matches[key] = mf +// resolvedArtists holds the agent artists resolved to artist-table rows, keyed by the query index +// that owns them. Routing is always by stable artist ID, never by name. +type resolvedArtists struct { + byQuery map[int]map[string]struct{} // query index -> set of resolved artist IDs + mbid map[string]string // artist ID -> its MBID (from the artist table) + allIDs []string // every resolved artist ID, for the track lookup +} + +// resolveArtists resolves every artist of every query to artist-table rows. Artists that carry a +// Navidrome ID are owned directly (no name/MBID lookup). The remaining names/MBIDs are resolved in +// one batched query. Ownership is recorded per query index. +func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (resolvedArtists, error) { + res := resolvedArtists{ + byQuery: make(map[int]map[string]struct{}, len(queries)), + mbid: make(map[string]string), + } + allIDs := map[string]struct{}{} // de-dupe across fast-path + resolved + + // One pending entry per non-ID artist (carrying the query that owns it). ID-bearing artists + // take the fast-path and are owned directly. + type pendingArtist struct { + name, mbid string + query int + } + var pending []pendingArtist + for _, iq := range queries { + for _, a := range iq.query.artists { + if a.id != "" { + addToSet(res.byQuery, iq.index, a.id) // ID fast-path: own directly + allIDs[a.id] = struct{}{} + continue + } + pending = append(pending, pendingArtist{name: a.name, mbid: a.mbid, query: iq.index}) + } + } + // query indices that supplied each order name / each MBID (skip the empty key — an artist may + // have only one of name/mbid). + nameToQueries := map[string][]int{} + mbidToQueries := map[string][]int{} + for _, p := range pending { + if p.name != "" { + nameToQueries[p.name] = append(nameToQueries[p.name], p.query) + } + if p.mbid != "" { + mbidToQueries[p.mbid] = append(mbidToQueries[p.mbid], p.query) + } + } + + // Query the artist table for name/MBID artists AND for the fast-path IDs (so their MBIDs are + // available for specificity scoring). + var filter squirrel.Or + if len(nameToQueries) > 0 { + filter = append(filter, squirrel.Eq{"order_artist_name": slices.Collect(maps.Keys(nameToQueries))}) + } + if len(mbidToQueries) > 0 { + filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))}) + } + if len(allIDs) > 0 { + filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))}) + } + if len(filter) > 0 { + artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter}) + if err != nil { + return resolvedArtists{}, err + } + for _, a := range artists { + res.mbid[a.ID] = a.MbzArtistID + allIDs[a.ID] = struct{}{} + for _, idx := range nameToQueries[a.OrderArtistName] { + addToSet(res.byQuery, idx, a.ID) + } + if a.MbzArtistID != "" { + for _, idx := range mbidToQueries[a.MbzArtistID] { + addToSet(res.byQuery, idx, a.ID) } } } } - return matches, nil + + res.allIDs = slices.Collect(maps.Keys(allIDs)) + return res, nil +} + +func addToSet(m map[int]map[string]struct{}, k int, v string) { + if m[k] == nil { + m[k] = map[string]struct{}{} + } + m[k][v] = struct{}{} +} + +// scoredTrack is a candidate track for a query; overlap is how many of the query's distinct +// artist IDs the track credits. +type scoredTrack struct { + sanitizedTrack + overlap int +} + +// queryAccum tallies, for one track against one query, the overlap count, the credited artists' +// owned IDs, and their MBIDs. +type queryAccum struct { + overlap int + ids map[string]struct{} + mbids map[string]struct{} +} + +func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[int][]scoredTrack { + queriesByArtist := make(map[string][]int) + for idx, ids := range r.byQuery { + for id := range ids { + queriesByArtist[id] = append(queriesByArtist[id], idx) + } + } + + byQuery := make(map[int][]scoredTrack, len(r.byQuery)) + for i := range tracks { + acc := map[int]*queryAccum{} + credited := map[string]struct{}{} + for _, p := range tracks[i].Participants[model.RoleArtist] { + if _, dup := credited[p.ID]; dup { + continue + } + owners, owned := queriesByArtist[p.ID] + if !owned { + continue + } + credited[p.ID] = struct{}{} + mbid := r.mbid[p.ID] // "" if not in the artist-table result + for _, idx := range owners { + a := acc[idx] + if a == nil { + a = &queryAccum{ids: map[string]struct{}{}, mbids: map[string]struct{}{}} + acc[idx] = a + } + a.overlap++ + a.ids[p.ID] = struct{}{} + if mbid != "" { + a.mbids[mbid] = struct{}{} + } + } + } + for idx, a := range acc { + byQuery[idx] = append(byQuery[idx], scoredTrack{ + sanitizedTrack: newSanitizedTrack(&tracks[i], a.ids, a.mbids), + overlap: a.overlap, + }) + } + } + return byQuery +} + +// fetchTracksCreditedTo fetches every non-missing track credited to any of the given artists as +// the main artist (role='artist', not albumartist — that avoids tribute/compilation false +// positives). The non-correlated id IN (subquery) materializes the matching ids once from the +// media_file_artists(artist_id) covering index, far cheaper than a correlated EXISTS that re-runs +// per row. That form isn't expressible via the repository's role filters, so the raw squirrel.Expr +// keeps the media_file_artists schema knowledge here; a dedicated repository method would be the +// cleaner home if this is reused. +func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string) (model.MediaFiles, error) { + if len(artistIDs) == 0 { + return nil, nil + } + args := slice.Map(artistIDs, func(id string) any { return id }) + return m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Expr( + "media_file.id IN (SELECT media_file_id FROM media_file_artists "+ + "WHERE role = 'artist' AND artist_id IN ("+squirrel.Placeholders(len(artistIDs))+"))", args...), + squirrel.Eq{"missing": false}, + }, + Sort: "starred desc, rating desc, year asc, compilation asc", + }) } // durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration @@ -412,34 +566,32 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 } // findBestMatch finds the best matching track using combined title/album similarity and specificity scoring. -func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) { +func (m *Matcher) findBestMatch(q songQuery, candidates []scoredTrack, threshold float64) (model.MediaFile, bool) { var bestMatch model.MediaFile bestScore := matchScore{titleSimilarity: -1} found := false - for _, t := range sanitizedTracks { - titleSim := similarityRatio(q.title, t.title) - + preferStarred := conf.Server.Matcher.PreferStarred + for _, c := range candidates { + titleSim := similarityRatio(q.title, c.title) if titleSim < threshold { continue } - var albumSim float64 if q.album != "" { - albumSim = similarityRatio(q.album, t.album) + albumSim = similarityRatio(q.album, c.album) } - score := matchScore{ titleSimilarity: titleSim, - durationProximity: durationProximity(q.durationMs, t.mf.Duration), - preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf), + durationProximity: durationProximity(q.durationMs, c.mf.Duration), + preferredMatch: preferStarred && isPreferredTrack(c.mf), albumSimilarity: albumSim, - specificityLevel: computeSpecificityLevel(q, t, threshold), + specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold), + artistOverlap: c.overlap, } - if score.betterThan(bestScore) { bestScore = score - bestMatch = *t.mf + bestMatch = *c.mf found = true } } @@ -450,66 +602,34 @@ func isPreferredTrack(mf *model.MediaFile) bool { return mf.Starred || mf.Rating >= 4 } -// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. -func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { - var queries []songQuery - for _, s := range songs { - if songMatchedIn(s, priorMatches...) { - continue - } - queries = append(queries, songQuery{ - title: str.SanitizeFieldForSorting(s.Name), - artist: str.SanitizeFieldForSortingNoArticle(s.Artist), - artistMBID: s.ArtistMBID, - album: str.SanitizeFieldForSorting(s.Album), - albumMBID: s.AlbumMBID, - durationMs: s.Duration, - }) - } - return queries -} - -// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching -// library tracks using priority order: ID > MBID > ISRC > title+artist. -func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { +// orderAndDedup builds the final ordered result from the per-index matches, +// applying the count limit and deduplication. A library track is added at most +// once unless the same input song appears more than once (callers rely on that +// 1:1 positional behavior for identical duplicate inputs). +func orderAndDedup(songs []agents.Song, matches map[int]model.MediaFile, count int) model.MediaFiles { mfs := make(model.MediaFiles, 0, len(songs)) - addedBy := make(map[string]agents.Song, len(songs)) + addedBy := make(map[string]int, len(songs)) - for _, t := range songs { + for i, s := range songs { if len(mfs) == count { break } - - mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist) + mf, found := matches[i] if !found { continue } - - if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - if t != prevSong { + if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded { + if !s.Equals(songs[prevIdx]) { continue } } else { - addedBy[mf.ID] = t + addedBy[mf.ID] = i } - mfs = append(mfs, mf) } return mfs } -// findMatchingTrack looks up a song in the match maps using priority order. -func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) { - if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found { - return mf, true - } - key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist) - if mf, ok := byTitleArtist[key]; ok { - return mf, true - } - return model.MediaFile{}, false -} - // similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. func similarityRatio(a, b string) float64 { if a == b { diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go index f111364c1..5b987d937 100644 --- a/core/matcher/matcher_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -1,6 +1,9 @@ package matcher import ( + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -51,3 +54,205 @@ var _ = Describe("similarityRatio", func() { Expect(ratio1).To(Equal(ratio2)) }) }) + +var _ = Describe("matcher internals", func() { + It("computeSpecificityLevel uses sanitizedTrack.artistMBIDs for artist-MBID levels", func() { + q := songQuery{ + title: "song", + artists: []queryArtist{{mbid: "artist-mbid-1"}}, + albumMBID: "album-mbid-1", + } + mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty + t := newSanitizedTrack(&mf, nil, map[string]struct{}{"artist-mbid-1": {}}) + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) + }) + + It("computeSpecificityLevel maximizes the level over all query artists", func() { + // First artist does not match; second matches by name with a matching album → level 3. + q := songQuery{ + title: "song", + album: "violator", + artists: []queryArtist{ + {name: "no match"}, + {name: "depeche mode"}, + }, + } + mf := model.MediaFile{Title: "Song", Artist: "Depeche Mode", Album: "Violator"} + t := newSanitizedTrack(&mf, nil, nil) + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(3)) + }) + + It("scores MBID specificity for any credited artist, not just the last", func() { + q := songQuery{ + title: "song", + artists: []queryArtist{ + {name: "drake", mbid: "mbz-drake"}, + {name: "future", mbid: "mbz-future"}, + }, + album: "wrong album", // force album mismatch so only MBID-level (2) is reachable, not 3+ + } + // Track credits BOTH MBIDs; with the old last-wins string this would only match one. + t := sanitizedTrack{ + mf: &model.MediaFile{}, + title: "song", + artist: "drake", + album: "some other album", + artistMBIDs: map[string]struct{}{"mbz-drake": {}, "mbz-future": {}}, + } + // Either artist's MBID matching yields level 2 (MBID, no album match). The point: it is + // reached via mbz-future too, which the old code would have dropped. + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(2)) + }) + + It("treats an ID-only artist (no name/MBID) as an identity match, unlocking album tiers", func() { + // A plugin that supplies only a Navidrome artist ID: name and mbid are empty. The ID is the + // strongest identity signal, so the track's album still elevates specificity above 0. + q := songQuery{ + title: "song", + artists: []queryArtist{{id: "artist-1"}}, + album: "violator", + albumMBID: "album-mbid-1", + } + // Track credits the owned artist ID; no MBID anywhere (untagged library / ID-only plugin). + mf := model.MediaFile{Title: "Song", Album: "Violator", MbzAlbumID: "album-mbid-1"} + t := newSanitizedTrack(&mf, map[string]struct{}{"artist-1": {}}, nil) + // Album MBID matches → level 5 via the ID identity, where the old code scored 0. + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) + + // Same artist, album name matches but no album MBID → level 4 via the ID identity. + q.albumMBID = "" + mf2 := model.MediaFile{Title: "Song", Album: "Violator"} + t2 := newSanitizedTrack(&mf2, map[string]struct{}{"artist-1": {}}, nil) + Expect(computeSpecificityLevel(q, t2, 0.85)).To(Equal(4)) + }) +}) + +var _ = Describe("groupQueries", func() { + It("builds one query per unmatched song carrying all artists", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].index).To(Equal(0)) + Expect(queries[0].query.title).To(Equal("song a")) + Expect(queries[0].query.artists).To(HaveLen(2)) + Expect(queries[0].query.artists[0].name).To(Equal("drake")) + Expect(queries[0].query.artists[1].name).To(Equal("future")) + }) + + It("strips leading articles from artist names", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "The Drake"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].name).To(Equal("drake")) + }) + + It("keeps an artist that carries only an ID (empty name)", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].id).To(Equal("ar-x")) + Expect(queries[0].query.artists[0].name).To(Equal("")) + }) + + It("keeps an artist that carries only an MBID (empty id and name)", func() { + // ListenBrainz collaborators arrive as MBID-only when the API supplies a combined display + // name; the MBID is a usable identity signal and must not be dropped. + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{MBID: "mbz-future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].id).To(Equal("")) + Expect(queries[0].query.artists[0].name).To(Equal("")) + Expect(queries[0].query.artists[0].mbid).To(Equal("mbz-future")) + }) + + It("drops a fully-empty artist (no id, name, or mbid) but keeps usable ones", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{}, {Name: "Future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].name).To(Equal("future")) + }) + + It("skips already-matched songs and songs with no usable artist", func() { + songs := []agents.Song{ + {Name: "Already Matched", Artists: []agents.Artist{{Name: "Drake"}}}, + {Name: "No Artist"}, + {Name: "Song C", Artists: []agents.Artist{{Name: "Future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{0: {ID: "done"}}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].index).To(Equal(2)) + Expect(queries[0].query.artists[0].name).To(Equal("future")) + }) +}) + +var _ = Describe("bucketTracks", func() { + It("scores tracks by how many of the query's artists they credit (overlap)", func() { + r := resolvedArtists{ + byQuery: map[int]map[string]struct{}{ + 0: {"ar-1": {}, "ar-2": {}}, + }, + mbid: map[string]string{}, + } + trackA := model.MediaFile{ID: "a", Title: "A", + Participants: artistParticipants( + model.Artist{ID: "ar-1", OrderArtistName: "one"}, + model.Artist{ID: "ar-2", OrderArtistName: "two"}, + ), + } + trackB := model.MediaFile{ID: "b", Title: "B", + Participants: artistParticipants(model.Artist{ID: "ar-1", OrderArtistName: "one"}), + } + byQuery := r.bucketTracks(model.MediaFiles{trackA, trackB}) + Expect(byQuery[0]).To(HaveLen(2)) + overlaps := map[string]int{} + for _, st := range byQuery[0] { + overlaps[st.mf.ID] = st.overlap + } + Expect(overlaps["a"]).To(Equal(2)) + Expect(overlaps["b"]).To(Equal(1)) + }) +}) + +var _ = Describe("resolveArtists ID fast-path", func() { + It("owns an artist supplied by ID without a name match", func() { + ctx := GinkgoT().Context() + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{ + {ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist", MbzArtistID: "mbz-x"}, + }) + ds := &tests.MockDataStore{MockedArtist: artistRepo} + m := New(ds) + + queries := []indexedQuery{ + {index: 0, query: songQuery{title: "song", artists: []queryArtist{{id: "ar-x"}}}}, + } + res, err := m.resolveArtists(ctx, queries) + Expect(err).ToNot(HaveOccurred()) + Expect(res.byQuery[0]).To(HaveKey("ar-x")) + Expect(res.allIDs).To(ContainElement("ar-x")) + Expect(res.mbid["ar-x"]).To(Equal("mbz-x")) + }) +}) + +// artistParticipants builds a Participants map crediting the given artists under RoleArtist. +func artistParticipants(artists ...model.Artist) model.Participants { + list := make(model.ParticipantList, len(artists)) + for i, a := range artists { + list[i] = model.Participant{Artist: a} + } + return model.Participants{model.RoleArtist: list} +} diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 42e3ec88d..a46db8a09 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -3,6 +3,7 @@ package matcher_test import ( "context" "errors" + "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" @@ -19,6 +20,7 @@ import ( var _ = Describe("Matcher", func() { var ds model.DataStore var mediaFileRepo *mockMediaFileRepo + var artistRepo *mockArtistRepo var ctx context.Context var m *matcher.Matcher @@ -26,11 +28,13 @@ var _ = Describe("Matcher", func() { ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) mediaFileRepo = newMockMediaFileRepo() + artistRepo = newMockArtistRepo() DeferCleanup(func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) ds = &tests.MockDataStore{ MockedMediaFile: mediaFileRepo, + MockedArtist: artistRepo, } m = matcher.New(ds) }) @@ -53,26 +57,49 @@ var _ = Describe("Matcher", func() { Return(matches, nil).Once() } - // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return - // early without hitting the DB) don't cause test failures for unexpected calls. Call - // this after expect*Phase for the phases the test actually wants to verify. - allowOtherPhases := func() { + // allowIdentifierPhases installs .Maybe() catch-alls for the ID/MBID/ISRC phases so + // tests that only care about the title phase don't fail on those unexpected calls. + allowIdentifierPhases := func() { mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). Return(model.MediaFiles{}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + } + + // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return + // early without hitting the DB) don't cause test failures for unexpected calls. Call + // this after expect*Phase for the phases the test actually wants to verify. + allowOtherPhases := func() { + allowIdentifierPhases() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). Return(model.MediaFiles{}, nil).Maybe() } - // setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise - // the title+artist phase. The title phase uses .Maybe() because it may short-circuit - // when no songs have an artist. - setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) { - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). - Return(artistTracks, nil).Maybe() + // allowTitlePhase wires title matching from a list of library tracks. Each track must carry + // Participants[RoleArtist] with the artist IDs that credit it; the helper derives the artist + // rows the artist resolution returns from those participants, then returns the tracks from + // the track-fetch query. + allowTitlePhase := func(tracks model.MediaFiles) { + // Artist resolution: build artist rows from the tracks' participants. + seen := map[string]model.Artist{} + for _, t := range tracks { + for _, p := range t.Participants[model.RoleArtist] { + if _, ok := seen[p.ID]; !ok { + seen[p.ID] = p.Artist + } + } + } + artists := make(model.Artists, 0, len(seen)) + for _, a := range seen { + artists = append(artists, a) + } + artistRepo.On("GetAll", mock.Anything).Return(artists, nil).Maybe() + // Track fetch (media_file_artists subquery). + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(tracks, nil).Maybe() } Describe("MatchSongs", func() { @@ -80,7 +107,7 @@ var _ = Describe("Matcher", func() { It("matches songs with an ID field to MediaFiles by ID", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {ID: "track-1", Name: "Some Song", Artist: "Some Artist"}, + {ID: "track-1", Name: "Some Song", Artists: []agents.Artist{{Name: "Some Artist"}}}, } idMatch := model.MediaFile{ ID: "track-1", Title: "Some Song", Artist: "Some Artist", @@ -98,7 +125,7 @@ var _ = Describe("Matcher", func() { It("matches songs with MBID to tracks with matching mbz_recording_id", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}, + {Name: "Paranoid Android", MBID: "abc-123", Artists: []agents.Artist{{Name: "Radiohead"}}}, } mbidMatch := model.MediaFile{ ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead", @@ -117,7 +144,7 @@ var _ = Describe("Matcher", func() { It("matches songs with ISRC to tracks with matching ISRC tag", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}, + {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artists: []agents.Artist{{Name: "Radiohead"}}}, } isrcMatch := model.MediaFile{ ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead", @@ -136,12 +163,13 @@ var _ = Describe("Matcher", func() { It("matches songs by title and artist name", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, } titleMatch := model.MediaFile{ ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) + allowTitlePhase(model.MediaFiles{titleMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -151,12 +179,13 @@ var _ = Describe("Matcher", func() { It("matches songs with fuzzy title similarity", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}}, } fuzzyMatch := model.MediaFile{ ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -166,12 +195,14 @@ var _ = Describe("Matcher", func() { It("does not match completely different titles", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}}, } differentTracks := model.MediaFiles{ - {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, + {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), + }, } - setupTitleOnlyExpectations(differentTracks) + allowTitlePhase(differentTracks) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -182,13 +213,14 @@ var _ = Describe("Matcher", func() { It("removes duplicates when different input songs match the same library track", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, - {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, + {Name: "Bohemian Rhapsody (Live)", Artists: []agents.Artist{{Name: "Queen"}}}, + {Name: "Bohemian Rhapsody (Original Mix)", Artists: []agents.Artist{{Name: "Queen"}}}, } libraryTrack := model.MediaFile{ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -198,13 +230,14 @@ var _ = Describe("Matcher", func() { It("preserves duplicates when identical input songs match the same library track", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, } libraryTrack := model.MediaFile{ ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -220,7 +253,7 @@ var _ = Describe("Matcher", func() { // and short-circuit the MBID phase entirely, so no MBID fetch should // occur even though an mbz_recording_id exists in the input. songs := []agents.Song{ - {ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"}, + {ID: "track-id", Name: "Song", MBID: "mbid-1", Artists: []agents.Artist{{Name: "Artist"}}}, } idMatch := model.MediaFile{ ID: "track-id", Title: "Song", Artist: "Artist", @@ -238,16 +271,22 @@ var _ = Describe("Matcher", func() { It("returns at most 'count' results", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song C", Artist: "Artist"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song C", Artists: []agents.Artist{{Name: "Artist"}}}, } tracks := model.MediaFiles{ - {ID: "a", Title: "Song A", Artist: "Artist"}, - {ID: "b", Title: "Song B", Artist: "Artist"}, - {ID: "c", Title: "Song C", Artist: "Artist"}, + {ID: "a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, + {ID: "b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, + {ID: "c", Title: "Song C", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, } - setupTitleOnlyExpectations(tracks) + allowTitlePhase(tracks) result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -261,14 +300,277 @@ var _ = Describe("Matcher", func() { Expect(result).To(BeEmpty()) }) }) + + Context("artist grouping", func() { + It("groups title-phase tracks by participant artist ID, not display Artist", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Daft Punk"}}}, + } + // Display Artist differs from the query artist; only the participant + // with order_artist_name "daft punk" routes to this query bucket. + track := model.MediaFile{ + ID: "oan-track", Title: "Song A", + Artist: "Daft Punk feat. Pharrell", + Participants: artistParticipants( + model.Artist{ID: "dp", Name: "Daft Punk", OrderArtistName: "daft punk"}, + model.Artist{ID: "ph", Name: "Pharrell", OrderArtistName: "pharrell"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("oan-track")) + }) + + It("matches a track that credits the searched artist as a collaborator", func() { + songs := []agents.Song{ + {Name: "Crazy", Artists: []agents.Artist{{Name: "INXS"}}}, + } + // "Par-T-One vs. INXS" — display Artist is the collaboration, but INXS is a + // credited artist participant. Searching INXS must match it. + track := model.MediaFile{ + ID: "collab", Title: "Crazy", Artist: "Par-T-One vs. INXS", + Participants: artistParticipants( + model.Artist{ID: "a-partone", Name: "Par-T-One", OrderArtistName: "par-t-one"}, + model.Artist{ID: "a-inxs", Name: "INXS", OrderArtistName: "inxs"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("collab")) + }) + + It("does not match a track where the searched artist is only the album artist", func() { + songs := []agents.Song{ + {Name: "Qmart", Artists: []agents.Artist{{Name: "808 State"}}}, + } + // Track performed by Björk on an "808 State" compilation: 808 State is the + // albumartist, Björk is the performer. Searching 808 State must NOT match it. + track := model.MediaFile{ + ID: "comp", Title: "Qmart", Artist: "Björk", + Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "a-bjork", Name: "Björk", OrderArtistName: "bjork"}}, + }, + model.RoleAlbumArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, + }, + }, + } + // Artist resolution returns "808 state" only if some artist row matches; here the + // album-artist participant exists but is NOT role='artist', so the track-fetch query's + // EXISTS (role='artist') would not return the track in production. The mock + // returns it anyway; back-mapping must drop it because no role='artist' + // participant is a resolved artist for the query "808 state". + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{track}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("resolves the artist by ArtistMBID when the name differs", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Typo Artist", MBID: "mbid-9"}}}, + } + track := model.MediaFile{ + ID: "by-mbid", Title: "Song A", Artist: "Correct Artist", + Participants: artistParticipants(model.Artist{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}), + } + // Artist resolution returns the artist matched by mbz_artist_id; its order name + // ("correct artist") differs from the query name ("typo artist"), so + // resolution must come from the MBID branch. + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{track}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("by-mbid")) + }) + + It("resolves both queries when two share one ArtistMBID under different names", func() { + // Two agent results for the same MusicBrainz artist but spelled differently + // (an alias). Both must match the artist's track via the shared MBID. + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Alias One", MBID: "mbid-shared"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Alias Two", MBID: "mbid-shared"}}}, + } + artist := model.Artist{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"} + trackA := model.MediaFile{ID: "ta", Title: "Song A", Artist: "Canonical", Participants: artistParticipants(artist)} + trackB := model.MediaFile{ID: "tb", Title: "Song B", Artist: "Canonical", Participants: artistParticipants(artist)} + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{trackA, trackB}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect([]string{result[0].ID, result[1].ID}).To(ConsistOf("ta", "tb")) + }) + }) + + // These tests register their own track-fetch expectations per-test (to inject + // an error), so they use allowIdentifierPhases — NOT allowOtherPhases, which would + // add a .Maybe() title-phase catch-all that masks the injected error. + Context("title phase DB errors", func() { + It("returns an error when the title query fails and nothing else matched", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist Two"}}}, + } + allowIdentifierPhases() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ + {ID: "a1", Name: "Artist One", OrderArtistName: "artist one"}, + {ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}, + }, nil) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(nil, errors.New("db down")) + + _, err := m.MatchSongs(ctx, songs, 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("db down")) + }) + + It("keeps exact-phase matches when the title query fails", func() { + songs := []agents.Song{ + {ID: "track-1", Name: "Exact Song", Artists: []agents.Artist{{Name: "Exact Artist"}}}, + {Name: "Fuzzy Song", Artists: []agents.Artist{{Name: "Fuzzy Artist"}}}, + } + idMatch := model.MediaFile{ID: "track-1", Title: "Exact Song", Artist: "Exact Artist"} + expectIDPhase(model.MediaFiles{idMatch}) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(model.MediaFiles{}, nil).Maybe() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ + {ID: "fa", Name: "Fuzzy Artist", OrderArtistName: "fuzzy artist"}, + }, nil) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(nil, errors.New("db down")) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-1")) + }) + }) + + Context("multiple artists", func() { + It("prefers the track that shares more of the song's artists", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Life Is Good", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, + } + // Both candidates have display Artist "Drake", so they tie at specificity level 1 + // (name match). The deciding factor is artistOverlap: "both" credits Drake AND + // Future (overlap 2), "one" credits only Drake (overlap 1). + bothArtists := model.MediaFile{ + ID: "both", Title: "Life Is Good", Artist: "Drake", + Participants: artistParticipants( + model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, + model.Artist{ID: "future", Name: "Future", OrderArtistName: "future"}, + ), + } + oneArtist := model.MediaFile{ + ID: "one", Title: "Life Is Good", Artist: "Drake", + Participants: artistParticipants( + model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, + ), + } + allowTitlePhase(model.MediaFiles{oneArtist, bothArtists}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("both")) + }) + + It("matches a single-artist song against a track crediting several artists", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Life Is Good", Artists: []agents.Artist{{Name: "Future"}}}, + } + track := model.MediaFile{ + ID: "multi", Title: "Life Is Good", Artist: "Future feat. Drake", + Participants: artistParticipants( + model.Artist{ID: "future", Name: "Future", OrderArtistName: "future"}, + model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("multi")) + }) + + It("matches by a directly-supplied Navidrome artist ID (fast-path)", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}}, + } + track := model.MediaFile{ + ID: "by-id", Title: "Song A", Artist: "Some Artist", + Participants: artistParticipants( + model.Artist{ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("by-id")) + }) + + It("prefers a higher artist-overlap track over a starred lower-overlap track", func() { + conf.Server.Matcher.PreferStarred = true + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Collab Hit", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, + } + // Shares only Drake (overlap 1) but starred. + starredOne := model.MediaFile{ + ID: "starred-one", Title: "Collab Hit", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "id-drake", Name: "Drake", OrderArtistName: "drake"}), + } + // Shares both (overlap 2), not starred. + shareTwo := model.MediaFile{ + ID: "share-two", Title: "Collab Hit", + Participants: artistParticipants( + model.Artist{ID: "id-drake", Name: "Drake", OrderArtistName: "drake"}, + model.Artist{ID: "id-future", Name: "Future", OrderArtistName: "future"}, + ), + } + allowTitlePhase(model.MediaFiles{starredOne, shareTwo}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("share-two")) // overlap outranks the starred flag + }) + }) }) Describe("MatchSongsIndexed", func() { It("returns index-keyed map of matched songs", func() { songs := []agents.Song{ - {ID: "track-1", Name: "Song One", Artist: "Artist A"}, - {ID: "track-2", Name: "Song Two", Artist: "Artist B"}, - {ID: "track-3", Name: "Song Three", Artist: "Artist C"}, + {ID: "track-1", Name: "Song One", Artists: []agents.Artist{{Name: "Artist A"}}}, + {ID: "track-2", Name: "Song Two", Artists: []agents.Artist{{Name: "Artist B"}}}, + {ID: "track-3", Name: "Song Three", Artists: []agents.Artist{{Name: "Artist C"}}}, } mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"} mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"} @@ -287,8 +589,8 @@ var _ = Describe("Matcher", func() { It("preserves original indices when some songs don't match", func() { songs := []agents.Song{ - {Name: "Unknown Song", Artist: "Unknown Artist"}, - {ID: "track-1", Name: "Known Song", Artist: "Known Artist"}, + {Name: "Unknown Song", Artists: []agents.Artist{{Name: "Unknown Artist"}}}, + {ID: "track-1", Name: "Known Song", Artists: []agents.Artist{{Name: "Known Artist"}}}, } mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"} @@ -319,16 +621,18 @@ var _ = Describe("Matcher", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), } songs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid-123"}}, Album: "Violator", AlbumMBID: "album-mbid-456"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -340,15 +644,17 @@ var _ = Describe("Matcher", func() { It("matches by title + artist name + album name when MBIDs unavailable", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -360,15 +666,17 @@ var _ = Describe("Matcher", func() { It("matches by title + artist only when album info unavailable", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode"}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -382,7 +690,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song"}, } - setupTitleOnlyExpectations(model.MediaFiles{}) + allowTitlePhase(model.MediaFiles{}) result, err := m.MatchSongs(ctx, songs, 5) @@ -391,17 +699,23 @@ var _ = Describe("Matcher", func() { }) It("returns distinct matches for each artist's version (covers scenario)", func() { - cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"} - cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"} - cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"} - - songs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, - {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, + cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), + } + cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits", + Participants: artistParticipants(model.Artist{ID: "ray-charles", Name: "Ray Charles", OrderArtistName: "ray charles"}), + } + cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way", + Participants: artistParticipants(model.Artist{ID: "sinatra", Name: "Frank Sinatra", OrderArtistName: "frank sinatra"}), } - setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) + songs := []agents.Song{ + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "Ray Charles"}}, Album: "Greatest Hits"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "Frank Sinatra"}}, Album: "My Way"}, + } + + allowTitlePhase(model.MediaFiles{cover1, cover2, cover3}) result, err := m.MatchSongs(ctx, songs, 5) @@ -415,21 +729,24 @@ var _ = Describe("Matcher", func() { preciseMatch := model.MediaFile{ ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), } lessAccurateMatch := model.MediaFile{ ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", - MbzArtistID: "mbid-1", + MbzArtistID: "mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), } artistTwoMatch := model.MediaFile{ ID: "artist-two", Title: "Song B", Artist: "Artist Two", + Participants: artistParticipants(model.Artist{ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}), } songs := []agents.Song{ - {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, - {Name: "Song B", Artist: "Artist Two"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One", MBID: "mbid-1"}}, Album: "Album One", AlbumMBID: "album-mbid-1"}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist Two"}}}, } - setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + allowTitlePhase(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -438,6 +755,31 @@ var _ = Describe("Matcher", func() { Expect(result[0].ID).To(Equal("precise")) Expect(result[1].ID).To(Equal("artist-two")) }) + + It("uses the resolved artist MBID for specificity (level 5)", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One", MBID: "mbid-1"}}, Album: "Album One", AlbumMBID: "album-mbid-1"}, + } + // Two tracks with the same title and album; only the one whose resolved artist + // carries mbid-1 (and whose album MBID matches) wins via Level 5. Without the + // resolved MBID, both tracks tie at Level 3 (name+album) and the first wins by + // chance — verifiable by RED-proof: see task-2-report.md. + precise := model.MediaFile{ + ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "album-mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), + } + other := model.MediaFile{ + ID: "other", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "wrong-album-mbid", + Participants: artistParticipants(model.Artist{ID: "a1b", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: ""}), + } + // Artist resolution returns both a1 (by name+mbid) and a1b (by name). + allowTitlePhase(model.MediaFiles{other, precise}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("precise")) + }) }) Describe("fuzzy matching thresholds", func() { @@ -446,13 +788,15 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, + {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", + Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -465,13 +809,15 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}}, } artistTracks := model.MediaFiles{ - {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -486,13 +832,15 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, + {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", + Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -506,13 +854,15 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 75 songs := []agents.Song{ - {Name: "Song", Artist: "Artist"}, + {Name: "Song", Artists: []agents.Artist{{Name: "Artist"}}}, } artistTracks := model.MediaFiles{ - {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, + {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -531,16 +881,18 @@ var _ = Describe("Matcher", func() { It("matches album with (Remaster) suffix", func() { songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, } correctMatch := model.MediaFile{ ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } wrongMatch := model.MediaFile{ ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -551,16 +903,18 @@ var _ = Describe("Matcher", func() { It("matches album with (Deluxe Edition) suffix", func() { songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } correctMatch := model.MediaFile{ ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -571,16 +925,18 @@ var _ = Describe("Matcher", func() { It("prefers exact album match over fuzzy album match", func() { songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } exactMatch := model.MediaFile{ ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } fuzzyMatch := model.MediaFile{ ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch, exactMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -589,46 +945,68 @@ var _ = Describe("Matcher", func() { Expect(result[0].ID).To(Equal("exact")) }) - It("prefers starred songs over better album match when enabled", func() { + It("prefers a more specific match over a starred track when PreferStarred is enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } starredTrack := model.MediaFile{ - ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true}, + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) - + allowTitlePhase(model.MediaFiles{albumMatch, starredTrack}) result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("starred")) + Expect(result[0].ID).To(Equal("album-match")) // specificity now outranks the starred flag }) - It("prefers 4-star songs over better album match when enabled", func() { + It("prefers a more specific match over a 4-star track when PreferStarred is enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } ratedTrack := model.MediaFile{ - ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4}, + ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", + Annotations: model.Annotations{Rating: 4}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) - + allowTitlePhase(model.MediaFiles{albumMatch, ratedTrack}) result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("rated")) + Expect(result[0].ID).To(Equal("album-match")) // specificity now outranks the 4-star rating + }) + + It("prefers a starred track when specificity and overlap are equal", func() { + conf.Server.Matcher.PreferStarred = true + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, + } + // Both credit the same single artist and the same album → equal specificity AND equal overlap. + plain := model.MediaFile{ + ID: "plain", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), + } + starred := model.MediaFile{ + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), + } + allowTitlePhase(model.MediaFiles{plain, starred}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("starred")) // preferred still wins the tie }) }) @@ -639,16 +1017,18 @@ var _ = Describe("Matcher", func() { It("prefers tracks with matching duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } correctMatch := model.MediaFile{ ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } wrongDuration := model.MediaFile{ ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongDuration, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -659,13 +1039,14 @@ var _ = Describe("Matcher", func() { It("matches tracks with close duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } closeDuration := model.MediaFile{ ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) + allowTitlePhase(model.MediaFiles{closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -676,16 +1057,18 @@ var _ = Describe("Matcher", func() { It("prefers closer duration over farther duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } closeDuration := model.MediaFile{ ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } farDuration := model.MediaFile{ ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) + allowTitlePhase(model.MediaFiles{farDuration, closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -696,13 +1079,14 @@ var _ = Describe("Matcher", func() { It("still matches when no tracks have matching duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } differentDuration := model.MediaFile{ ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) + allowTitlePhase(model.MediaFiles{differentDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -713,16 +1097,18 @@ var _ = Describe("Matcher", func() { It("prefers title match over duration match when titles differ", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } differentTitle := model.MediaFile{ ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } correctTitle := model.MediaFile{ ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) + allowTitlePhase(model.MediaFiles{differentTitle, correctTitle}) result, err := m.MatchSongs(ctx, songs, 5) @@ -733,13 +1119,14 @@ var _ = Describe("Matcher", func() { It("matches without duration filtering when agent duration is 0", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 0}, } anyTrack := model.MediaFile{ ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) + allowTitlePhase(model.MediaFiles{anyTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -750,13 +1137,14 @@ var _ = Describe("Matcher", func() { It("handles very short songs with close duration", func() { songs := []agents.Song{ - {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, + {Name: "Short Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 30000}, } shortTrack := model.MediaFile{ ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) + allowTitlePhase(model.MediaFiles{shortTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -764,6 +1152,30 @@ var _ = Describe("Matcher", func() { Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("short")) }) + + It("matches same title+artist songs to their own closest-duration track", func() { + songs := []agents.Song{ + {Name: "Same Song", Artists: []agents.Artist{{Name: "Same Artist"}}, Duration: 180000}, + {Name: "Same Song", Artists: []agents.Artist{{Name: "Same Artist"}}, Duration: 240000}, + } + shortTrack := model.MediaFile{ + ID: "short", Title: "Same Song", Artist: "Same Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), + } + longTrack := model.MediaFile{ + ID: "long", Title: "Same Song", Artist: "Same Artist", Duration: 240.0, + Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), + } + + allowTitlePhase(model.MediaFiles{shortTrack, longTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("short")) + Expect(result[1].ID).To(Equal("long")) + }) }) Describe("deduplication edge cases", func() { @@ -773,16 +1185,17 @@ var _ = Describe("Matcher", func() { It("handles mixed scenario with both identical and different input songs", func() { songs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, + {Name: "Yesterday (Remastered)", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "1"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, + {Name: "Yesterday (Anthology)", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Anthology"}, } libraryTrack := model.MediaFile{ ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -794,15 +1207,21 @@ var _ = Describe("Matcher", func() { It("does not deduplicate songs that match different library tracks", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song C", Artist: "Artist"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song C", Artists: []agents.Artist{{Name: "Artist"}}}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) + allowTitlePhase(model.MediaFiles{trackA, trackB, trackC}) result, err := m.MatchSongs(ctx, songs, 5) @@ -815,15 +1234,19 @@ var _ = Describe("Matcher", func() { It("respects count limit after deduplication", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song A (Live)", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song B (Remix)", Artist: "Artist"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song A (Live)", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B (Remix)", Artists: []agents.Artist{{Name: "Artist"}}}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) + allowTitlePhase(model.MediaFiles{trackA, trackB}) result, err := m.MatchSongs(ctx, songs, 2) @@ -866,6 +1289,27 @@ func (m *mockMediaFileRepo) SetError(hasError bool) { } } +type mockArtistRepo struct { + mock.Mock + model.ArtistRepository +} + +func newMockArtistRepo() *mockArtistRepo { + return &mockArtistRepo{} +} + +func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { + argsSlice := make([]any, len(options)) + for i, v := range options { + argsSlice[i] = v + } + args := m.Called(argsSlice...) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(model.Artists), args.Error(1) +} + // matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a // squirrel.And whose first element is a squirrel.Eq containing the given field name. func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool { @@ -895,3 +1339,30 @@ func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool { return hasField } } + +// artistParticipants builds a Participants map crediting the given artists under RoleArtist. +func artistParticipants(artists ...model.Artist) model.Participants { + list := make(model.ParticipantList, len(artists)) + for i, a := range artists { + list[i] = model.Participant{Artist: a} + } + return model.Participants{model.RoleArtist: list} +} + +// matchTracksByArtistQuery matches the title phase's track-fetch query, identified by its +// squirrel.And containing a squirrel.Expr whose SQL references media_file_artists. +func matchTracksByArtistQuery() func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok { + return false + } + for _, f := range and { + sql, _, err := f.ToSql() + if err == nil && strings.Contains(sql, "media_file_artists") { + return true + } + } + return false + } +} diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go index 81739b726..813fceea9 100644 --- a/core/sonic/sonic_test.go +++ b/core/sonic/sonic_test.go @@ -101,7 +101,7 @@ var _ = Describe("Sonic", func() { provider := &mockProvider{ similarResults: []sonic.SimilarResult{ - {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85}, + {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}}, Similarity: 0.85}, }, } loader.names = []string{"test-plugin"} diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index aef89cdd5..d65d8214a 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -45,16 +44,13 @@ var _ = Describe("LocalStorage", func() { }) Describe("newLocalStorage", func() { - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") - }) Context("with valid path", func() { It("should create a localStorage instance with correct path", func() { - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) localStorage := storage.(*localStorage) Expect(localStorage.u.Scheme).To(Equal("file")) @@ -94,10 +90,10 @@ var _ = Describe("LocalStorage", func() { err = os.Symlink(realDir, linkDir) Expect(err).ToNot(HaveOccurred()) - u, err := url.Parse("file://" + linkDir) + u, err := storage.LocalPathToURL(linkDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) @@ -110,10 +106,10 @@ var _ = Describe("LocalStorage", func() { // Use a non-existent path to trigger symlink resolution failure nonExistentPath := filepath.Join(tempDir, "non-existent") - u, err := url.Parse("file://" + nonExistentPath) + u, err := storage.LocalPathToURL(nonExistentPath) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) @@ -137,7 +133,9 @@ var _ = Describe("LocalStorage", func() { localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) - Expect(localStorage.u.Path).To(Equal("C:/music")) + // newLocalStorage re-joins the drive letter (u.Host) with u.Path via + // filepath.Join, which yields an OS-native (backslash) path on Windows. + Expect(localStorage.u.Path).To(Equal(filepath.Join("C:", "/music"))) }) }) @@ -159,10 +157,10 @@ var _ = Describe("LocalStorage", func() { It("falls back to the default extractor instead of crashing", func() { conf.Server.Scanner.Extractor = "nonexistent-extractor" - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) ls, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor)) @@ -171,16 +169,13 @@ var _ = Describe("LocalStorage", func() { }) Describe("localStorage.FS", func() { - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") - }) Context("with existing directory", func() { It("should return a localFS instance", func() { - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) Expect(musicFS).ToNot(BeNil()) @@ -193,10 +188,10 @@ var _ = Describe("LocalStorage", func() { Context("with non-existent directory", func() { It("should return an error", func() { nonExistentPath := filepath.Join(tempDir, "non-existent") - u, err := url.Parse("file://" + nonExistentPath) + u, err := storage.LocalPathToURL(nonExistentPath) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) _, err = storage.FS() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(nonExistentPath)) @@ -208,7 +203,6 @@ var _ = Describe("LocalStorage", func() { var testFile string BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // Create a test file testFile = filepath.Join(tempDir, "test.mp3") err := os.WriteFile(testFile, []byte("test data"), 0600) @@ -235,9 +229,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = expectedInfo - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -259,9 +253,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = incompleteInfo - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -288,9 +282,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["non-existent.mp3"] = incompleteInfo - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -303,9 +297,9 @@ var _ = Describe("LocalStorage", func() { It("should return the extractor error", func() { testExtractor.err = &extractorError{message: "extractor failed"} - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -334,9 +328,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = info1 testExtractor.results["test2.mp3"] = info2 - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -390,9 +384,8 @@ var _ = Describe("LocalStorage", func() { Describe("Storage registration", func() { It("should register localStorage for file scheme", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // This tests the init() function indirectly - storage, err := storage.For("file://" + tempDir) + storage, err := storage.For(tempDir) Expect(err).ToNot(HaveOccurred()) Expect(storage).To(BeAssignableToTypeOf(&localStorage{})) }) diff --git a/core/storage/storage.go b/core/storage/storage.go index b9fceb1fd..242965c99 100644 --- a/core/storage/storage.go +++ b/core/storage/storage.go @@ -25,6 +25,27 @@ func Register(schema string, c constructor) { registry[schema] = c } +// LocalPathToURL converts a bare OS filesystem path into an absolute file:// URL, +// applying the same slash-normalisation and per-component escaping the scanner +// relies on. It is the single source of truth for how a local path becomes a +// storage URL, shared by For and by storage tests. +func LocalPathToURL(osPath string) (url.URL, error) { + abs, _ := filepath.Abs(osPath) + abs = filepath.ToSlash(abs) + + // Properly escape each path component using URL standards + pathParts := strings.Split(abs, "/") + escapedParts := slice.Map(pathParts, func(s string) string { + return url.PathEscape(s) + }) + + u, err := url.Parse(LocalSchemaID + "://" + strings.Join(escapedParts, "/")) + if err != nil { + return url.URL{}, err + } + return *u, nil +} + // For returns a Storage implementation for the given URI. // It uses the schema part of the URI to find the correct registered // Storage constructor. @@ -34,24 +55,22 @@ func For(uri string) (Storage, error) { defer lock.RUnlock() parts := strings.Split(uri, "://") + var u *url.URL // Paths without schema are treated as file:// and use the default LocalStorage implementation if len(parts) < 2 { - uri, _ = filepath.Abs(uri) - uri = filepath.ToSlash(uri) - - // Properly escape each path component using URL standards - pathParts := strings.Split(uri, "/") - escapedParts := slice.Map(pathParts, func(s string) string { - return url.PathEscape(s) - }) - - uri = LocalSchemaID + "://" + strings.Join(escapedParts, "/") + parsed, err := LocalPathToURL(uri) + if err != nil { + return nil, err + } + u = &parsed + } else { + parsed, err := url.Parse(uri) + if err != nil { + return nil, err + } + u = parsed } - u, err := url.Parse(uri) - if err != nil { - return nil, err - } c, ok := registry[u.Scheme] if !ok { return nil, errors.New("schema '" + u.Scheme + "' not registered") diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go index 32fbac413..336b5a7a9 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -4,6 +4,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "testing" "github.com/navidrome/navidrome/tests" @@ -83,6 +84,43 @@ var _ = Describe("Storage", func() { Entry("multiple special chars", "/tmp/Song #1 & More?.mp3"), ) }) + + Describe("LocalPathToURL", func() { + It("builds a file:// URL from an absolute path", func() { + u, err := LocalPathToURL("/tmp/music") + Expect(err).ToNot(HaveOccurred()) + Expect(u.Scheme).To(Equal("file")) + Expect(u.Path).To(Equal("/tmp/music")) + }) + + It("escapes special characters and decodes them back in Path", func() { + u, err := LocalPathToURL("/tmp/Song #1 & More?.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(u.Path).To(Equal("/tmp/Song #1 & More?.mp3")) + }) + + It("produces the same url.URL that For uses for a bare path", func() { + registry = map[string]constructor{} + Register("file", func(url url.URL) Storage { return &fakeLocalStorage{u: url} }) + s, err := For("/tmp/music") + Expect(err).ToNot(HaveOccurred()) + direct, err := LocalPathToURL("/tmp/music") + Expect(err).ToNot(HaveOccurred()) + Expect(s.(*fakeLocalStorage).u).To(Equal(direct)) + }) + + // On Windows, library paths are drive-lettered (e.g. C:\Music). This + // exercises the real conversion to confirm a drive-letter path yields a + // usable file:// URL on the actual platform (no-op on Unix). + It("handles a Windows drive-letter path", func() { + if runtime.GOOS != "windows" { + Skip("Windows-specific path handling") + } + u, err := LocalPathToURL(`C:\Music`) + Expect(err).ToNot(HaveOccurred()) + Expect(u.Scheme).To(Equal("file")) + }) + }) }) type fakeLocalStorage struct { diff --git a/db/migrations/20200130083147_create_schema.go b/db/migrations/20200130083147_create_schema.go index 2fae4f57d..250fb00a5 100644 --- a/db/migrations/20200130083147_create_schema.go +++ b/db/migrations/20200130083147_create_schema.go @@ -12,9 +12,9 @@ func init() { goose.AddMigrationContext(Up20200130083147, Down20200130083147) } -func Up20200130083147(_ context.Context, tx *sql.Tx) error { +func Up20200130083147(ctx context.Context, tx *sql.Tx) error { log.Info("Creating DB Schema") - _, err := tx.Exec(` + _, err := tx.ExecContext(ctx, ` create table if not exists album ( id varchar(255) not null @@ -179,6 +179,6 @@ create table if not exists user return err } -func Down20200130083147(_ context.Context, tx *sql.Tx) error { +func Down20200130083147(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200131183653_standardize_item_type.go b/db/migrations/20200131183653_standardize_item_type.go index 471dc8002..bf7d9d5f7 100644 --- a/db/migrations/20200131183653_standardize_item_type.go +++ b/db/migrations/20200131183653_standardize_item_type.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200131183653, Down20200131183653) } -func Up20200131183653(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200131183653(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table search_dg_tmp ( id varchar(255) not null @@ -37,8 +37,8 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile'; return err } -func Down20200131183653(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Down20200131183653(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table search_dg_tmp ( id varchar(255) not null diff --git a/db/migrations/20200208222418_add_defaults_to_annotations.go b/db/migrations/20200208222418_add_defaults_to_annotations.go index d058b02c3..6807c8ad2 100644 --- a/db/migrations/20200208222418_add_defaults_to_annotations.go +++ b/db/migrations/20200208222418_add_defaults_to_annotations.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200208222418, Down20200208222418) } -func Up20200208222418(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200208222418(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` update annotation set play_count = 0 where play_count is null; update annotation set rating = 0 where rating is null; create table annotation_dg_tmp @@ -51,6 +51,6 @@ create index annotation_starred return err } -func Down20200208222418(_ context.Context, tx *sql.Tx) error { +func Down20200208222418(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200220143731_change_duration_to_float.go b/db/migrations/20200220143731_change_duration_to_float.go index 72b785ef8..ea5465ade 100644 --- a/db/migrations/20200220143731_change_duration_to_float.go +++ b/db/migrations/20200220143731_change_duration_to_float.go @@ -11,9 +11,9 @@ func init() { goose.AddMigrationContext(Up20200220143731, Down20200220143731) } -func Up20200220143731(_ context.Context, tx *sql.Tx) error { - notice(tx, "This migration will force the next scan to be a full rescan!") - _, err := tx.Exec(` +func Up20200220143731(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "This migration will force the next scan to be a full rescan!") + _, err := tx.ExecContext(ctx, ` create table media_file_dg_tmp ( id varchar(255) not null @@ -125,6 +125,6 @@ update media_file set updated_at = '0001-01-01'; return err } -func Down20200220143731(_ context.Context, tx *sql.Tx) error { +func Down20200220143731(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200310171621_enable_search_by_albumartist.go b/db/migrations/20200310171621_enable_search_by_albumartist.go index 373e0a475..73436c890 100644 --- a/db/migrations/20200310171621_enable_search_by_albumartist.go +++ b/db/migrations/20200310171621_enable_search_by_albumartist.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200310171621, Down20200310171621) } -func Up20200310171621(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to enable search by Album Artist!") - return forceFullRescan(tx) +func Up20200310171621(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to enable search by Album Artist!") + return forceFullRescan(ctx, tx) } -func Down20200310171621(_ context.Context, tx *sql.Tx) error { +func Down20200310171621(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200310181627_add_transcoding_and_player_tables.go b/db/migrations/20200310181627_add_transcoding_and_player_tables.go index 3be91ac35..ef872c4ae 100644 --- a/db/migrations/20200310181627_add_transcoding_and_player_tables.go +++ b/db/migrations/20200310181627_add_transcoding_and_player_tables.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200310181627, Down20200310181627) } -func Up20200310181627(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200310181627(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table transcoding ( id varchar(255) not null primary key, @@ -45,8 +45,8 @@ create table player return err } -func Down20200310181627(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Down20200310181627(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` drop table transcoding; drop table player; `) diff --git a/db/migrations/20200319211049_merge_search_into_main_tables.go b/db/migrations/20200319211049_merge_search_into_main_tables.go index f888cdd4c..a7a6ff0f9 100644 --- a/db/migrations/20200319211049_merge_search_into_main_tables.go +++ b/db/migrations/20200319211049_merge_search_into_main_tables.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200319211049, Down20200319211049) } -func Up20200319211049(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200319211049(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add full_text varchar(255) default ''; create index if not exists media_file_full_text @@ -33,10 +33,10 @@ drop table if exists search; if err != nil { return err } - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200319211049(_ context.Context, tx *sql.Tx) error { +func Down20200319211049(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200325185135_add_album_artist_id.go b/db/migrations/20200325185135_add_album_artist_id.go index f01f2c558..01537f886 100644 --- a/db/migrations/20200325185135_add_album_artist_id.go +++ b/db/migrations/20200325185135_add_album_artist_id.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200325185135, Down20200325185135) } -func Up20200325185135(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200325185135(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add album_artist_id varchar(255) default ''; create index album_artist_album_id @@ -26,10 +26,10 @@ create index media_file_artist_album_id if err != nil { return err } - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200325185135(_ context.Context, tx *sql.Tx) error { +func Down20200325185135(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200326090707_fix_album_artists_importing.go b/db/migrations/20200326090707_fix_album_artists_importing.go index c42e8c327..17afe37fe 100644 --- a/db/migrations/20200326090707_fix_album_artists_importing.go +++ b/db/migrations/20200326090707_fix_album_artists_importing.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200326090707, Down20200326090707) } -func Up20200326090707(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) +func Up20200326090707(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200326090707(_ context.Context, tx *sql.Tx) error { +func Down20200326090707(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200327193744_add_year_range_to_album.go b/db/migrations/20200327193744_add_year_range_to_album.go index 66f2b23e8..d9b048e22 100644 --- a/db/migrations/20200327193744_add_year_range_to_album.go +++ b/db/migrations/20200327193744_add_year_range_to_album.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200327193744, Down20200327193744) } -func Up20200327193744(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200327193744(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table album_dg_tmp ( id varchar(255) not null @@ -72,10 +72,10 @@ create index album_max_year if err != nil { return err } - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200327193744(_ context.Context, tx *sql.Tx) error { +func Down20200327193744(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200404214704_add_indexes.go b/db/migrations/20200404214704_add_indexes.go index 6207b0a3d..8b8d8607e 100644 --- a/db/migrations/20200404214704_add_indexes.go +++ b/db/migrations/20200404214704_add_indexes.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200404214704, Down20200404214704) } -func Up20200404214704(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200404214704(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists media_file_year on media_file (year); @@ -25,6 +25,6 @@ create index if not exists media_file_track_number return err } -func Down20200404214704(_ context.Context, tx *sql.Tx) error { +func Down20200404214704(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200409002249_enable_search_by_tracks_artists.go b/db/migrations/20200409002249_enable_search_by_tracks_artists.go index 22006c8af..482341a89 100644 --- a/db/migrations/20200409002249_enable_search_by_tracks_artists.go +++ b/db/migrations/20200409002249_enable_search_by_tracks_artists.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200409002249, Down20200409002249) } -func Up20200409002249(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!") - return forceFullRescan(tx) +func Up20200409002249(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to enable search by individual Artist in an Album!") + return forceFullRescan(ctx, tx) } -func Down20200409002249(_ context.Context, tx *sql.Tx) error { +func Down20200409002249(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go index 266dc087d..4aa502b4b 100644 --- a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go +++ b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200411164603, Down20200411164603) } -func Up20200411164603(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200411164603(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table playlist add created_at datetime; alter table playlist @@ -23,6 +23,6 @@ update playlist return err } -func Down20200411164603(_ context.Context, tx *sql.Tx) error { +func Down20200411164603(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200418110522_reindex_to_fix_album_years.go b/db/migrations/20200418110522_reindex_to_fix_album_years.go index 22b024cea..54e03f4c6 100644 --- a/db/migrations/20200418110522_reindex_to_fix_album_years.go +++ b/db/migrations/20200418110522_reindex_to_fix_album_years.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200418110522, Down20200418110522) } -func Up20200418110522(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to fix search Albums by year") - return forceFullRescan(tx) +func Up20200418110522(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to fix search Albums by year") + return forceFullRescan(ctx, tx) } -func Down20200418110522(_ context.Context, tx *sql.Tx) error { +func Down20200418110522(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200419222708_reindex_to_change_full_text_search.go b/db/migrations/20200419222708_reindex_to_change_full_text_search.go index efeb1bb84..89e3ccee5 100644 --- a/db/migrations/20200419222708_reindex_to_change_full_text_search.go +++ b/db/migrations/20200419222708_reindex_to_change_full_text_search.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200419222708, Down20200419222708) } -func Up20200419222708(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to change the search behaviour") - return forceFullRescan(tx) +func Up20200419222708(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(ctx, tx) } -func Down20200419222708(_ context.Context, tx *sql.Tx) error { +func Down20200419222708(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200423204116_add_sort_fields.go b/db/migrations/20200423204116_add_sort_fields.go index 4097a9d60..a51bb2270 100644 --- a/db/migrations/20200423204116_add_sort_fields.go +++ b/db/migrations/20200423204116_add_sort_fields.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200423204116, Down20200423204116) } -func Up20200423204116(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200423204116(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add order_artist_name varchar(255) collate nocase; alter table artist @@ -57,10 +57,10 @@ create index if not exists media_file_order_artist_name if err != nil { return err } - notice(tx, "A full rescan will be performed to change the search behaviour") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(ctx, tx) } -func Down20200423204116(_ context.Context, tx *sql.Tx) error { +func Down20200423204116(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200508093059_add_artist_song_count.go b/db/migrations/20200508093059_add_artist_song_count.go index aac78e698..72a47bc94 100644 --- a/db/migrations/20200508093059_add_artist_song_count.go +++ b/db/migrations/20200508093059_add_artist_song_count.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(Up20200508093059, Down20200508093059) } -func Up20200508093059(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200508093059(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add song_count integer default 0 not null; `) if err != nil { return err } - notice(tx, "A full rescan will be performed to calculate artists' song counts") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to calculate artists' song counts") + return forceFullRescan(ctx, tx) } -func Down20200508093059(_ context.Context, tx *sql.Tx) error { +func Down20200508093059(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200512104202_add_disc_subtitle.go b/db/migrations/20200512104202_add_disc_subtitle.go index b3e907d8d..29734e0c0 100644 --- a/db/migrations/20200512104202_add_disc_subtitle.go +++ b/db/migrations/20200512104202_add_disc_subtitle.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(Up20200512104202, Down20200512104202) } -func Up20200512104202(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200512104202(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add disc_subtitle varchar(255); `) if err != nil { return err } - notice(tx, "A full rescan will be performed to import disc subtitles") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to import disc subtitles") + return forceFullRescan(ctx, tx) } -func Down20200512104202(_ context.Context, tx *sql.Tx) error { +func Down20200512104202(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200516140647_add_playlist_tracks_table.go b/db/migrations/20200516140647_add_playlist_tracks_table.go index fcaae9d8e..59265e410 100644 --- a/db/migrations/20200516140647_add_playlist_tracks_table.go +++ b/db/migrations/20200516140647_add_playlist_tracks_table.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(Up20200516140647, Down20200516140647) } -func Up20200516140647(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200516140647(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table if not exists playlist_tracks ( id integer default 0 not null, @@ -28,7 +28,7 @@ create unique index if not exists playlist_tracks_pos if err != nil { return err } - rows, err := tx.Query("select id, tracks from playlist") + rows, err := tx.QueryContext(ctx, "select id, tracks from playlist") if err != nil { return err } @@ -49,7 +49,7 @@ create unique index if not exists playlist_tracks_pos return err } - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` create table playlist_dg_tmp ( id varchar(255) not null @@ -96,6 +96,6 @@ func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string) return nil } -func Down20200516140647(_ context.Context, tx *sql.Tx) error { +func Down20200516140647(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200608153717_referential_integrity.go b/db/migrations/20200608153717_referential_integrity.go index 2959237fa..c9c766f7e 100644 --- a/db/migrations/20200608153717_referential_integrity.go +++ b/db/migrations/20200608153717_referential_integrity.go @@ -11,46 +11,46 @@ func init() { goose.AddMigrationContext(Up20200608153717, Down20200608153717) } -func Up20200608153717(_ context.Context, tx *sql.Tx) error { +func Up20200608153717(ctx context.Context, tx *sql.Tx) error { // First delete dangling players - _, err := tx.Exec(` + _, err := tx.ExecContext(ctx, ` delete from player where user_name not in (select user_name from user)`) if err != nil { return err } // Also delete dangling players - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` delete from playlist where owner not in (select user_name from user)`) if err != nil { return err } // Also delete dangling playlist tracks - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` delete from playlist_tracks where playlist_id not in (select id from playlist)`) if err != nil { return err } // Add foreign key to player table - err = updatePlayer_20200608153717(tx) + err = updatePlayer_20200608153717(ctx, tx) if err != nil { return err } // Add foreign key to playlist table - err = updatePlaylist_20200608153717(tx) + err = updatePlaylist_20200608153717(ctx, tx) if err != nil { return err } // Add foreign keys to playlist_tracks table - return updatePlaylistTracks_20200608153717(tx) + return updatePlaylistTracks_20200608153717(ctx, tx) } -func updatePlayer_20200608153717(tx *sql.Tx) error { - _, err := tx.Exec(` +func updatePlayer_20200608153717(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table player_dg_tmp ( id varchar(255) not null @@ -77,8 +77,8 @@ alter table player_dg_tmp rename to player; return err } -func updatePlaylist_20200608153717(tx *sql.Tx) error { - _, err := tx.Exec(` +func updatePlaylist_20200608153717(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playlist_dg_tmp ( id varchar(255) not null @@ -108,8 +108,8 @@ create index playlist_name return err } -func updatePlaylistTracks_20200608153717(tx *sql.Tx) error { - _, err := tx.Exec(` +func updatePlaylistTracks_20200608153717(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playlist_tracks_dg_tmp ( id integer default 0 not null, @@ -133,6 +133,6 @@ create unique index playlist_tracks_pos return err } -func Down20200608153717(_ context.Context, tx *sql.Tx) error { +func Down20200608153717(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200706231659_add_default_transcodings.go b/db/migrations/20200706231659_add_default_transcodings.go index a498d32b0..e87481ae1 100644 --- a/db/migrations/20200706231659_add_default_transcodings.go +++ b/db/migrations/20200706231659_add_default_transcodings.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings) } -func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { - row := tx.QueryRow("SELECT COUNT(*) FROM transcoding") +func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding") var count int err := row.Scan(&count) if err != nil { @@ -38,6 +38,6 @@ func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { return nil } -func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func downAddDefaultTranscodings(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200710211442_add_playlist_path.go b/db/migrations/20200710211442_add_playlist_path.go index 8abfed6cf..32cc8d034 100644 --- a/db/migrations/20200710211442_add_playlist_path.go +++ b/db/migrations/20200710211442_add_playlist_path.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath) } -func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddPlaylistPath(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table playlist add path string default '' not null; @@ -23,6 +23,6 @@ alter table playlist return err } -func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error { +func downAddPlaylistPath(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200731095603_create_play_queues_table.go b/db/migrations/20200731095603_create_play_queues_table.go index d63a1ecb9..7a27137bc 100644 --- a/db/migrations/20200731095603_create_play_queues_table.go +++ b/db/migrations/20200731095603_create_play_queues_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable) } -func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreatePlayQueuesTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playqueue ( id varchar(255) not null primary key, @@ -32,6 +32,6 @@ create table playqueue return err } -func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { +func downCreatePlayQueuesTable(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200801101355_create_bookmark_table.go b/db/migrations/20200801101355_create_bookmark_table.go index fe68fafd7..df814d7b8 100644 --- a/db/migrations/20200801101355_create_bookmark_table.go +++ b/db/migrations/20200801101355_create_bookmark_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable) } -func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreateBookmarkTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table bookmark ( user_id varchar(255) not null @@ -49,6 +49,6 @@ alter table playqueue_dg_tmp rename to playqueue; return err } -func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error { +func downCreateBookmarkTable(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200819111809_drop_email_unique_constraint.go b/db/migrations/20200819111809_drop_email_unique_constraint.go index b2dd4285c..8259ad3fe 100644 --- a/db/migrations/20200819111809_drop_email_unique_constraint.go +++ b/db/migrations/20200819111809_drop_email_unique_constraint.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint) } -func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upDropEmailUniqueConstraint(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table user_dg_tmp ( id varchar(255) not null @@ -38,6 +38,6 @@ alter table user_dg_tmp rename to user; return err } -func downDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error { +func downDropEmailUniqueConstraint(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201003111749_add_starred_at_index.go b/db/migrations/20201003111749_add_starred_at_index.go index 7ee7a283f..b46430743 100644 --- a/db/migrations/20201003111749_add_starred_at_index.go +++ b/db/migrations/20201003111749_add_starred_at_index.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(Up20201003111749, Down20201003111749) } -func Up20201003111749(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201003111749(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists annotation_starred_at on annotation (starred_at); `) return err } -func Down20201003111749(_ context.Context, tx *sql.Tx) error { +func Down20201003111749(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201010162350_add_album_size.go b/db/migrations/20201010162350_add_album_size.go index f1182ab6c..df1fa8ca2 100644 --- a/db/migrations/20201010162350_add_album_size.go +++ b/db/migrations/20201010162350_add_album_size.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201010162350, Down20201010162350) } -func Up20201010162350(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201010162350(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add size integer default 0 not null; create index if not exists album_size @@ -28,7 +28,7 @@ where id not null;`) return err } -func Down20201010162350(_ context.Context, tx *sql.Tx) error { +func Down20201010162350(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20201012210022_add_artist_playlist_size.go b/db/migrations/20201012210022_add_artist_playlist_size.go index 4eb67f14e..1c738dd1e 100644 --- a/db/migrations/20201012210022_add_artist_playlist_size.go +++ b/db/migrations/20201012210022_add_artist_playlist_size.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201012210022, Down20201012210022) } -func Up20201012210022(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201012210022(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add size integer default 0 not null; create index if not exists artist_size @@ -40,6 +40,6 @@ update playlist set size = ifnull(( return err } -func Down20201012210022(_ context.Context, tx *sql.Tx) error { +func Down20201012210022(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201021085410_add_mbids.go b/db/migrations/20201021085410_add_mbids.go index 624bb1a67..53001fc73 100644 --- a/db/migrations/20201021085410_add_mbids.go +++ b/db/migrations/20201021085410_add_mbids.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201021085410, Down20201021085410) } -func Up20201021085410(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201021085410(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add mbz_track_id varchar(255); alter table media_file @@ -49,11 +49,11 @@ alter table artist if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func Down20201021085410(_ context.Context, tx *sql.Tx) error { +func Down20201021085410(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20201021093209_add_media_file_indexes.go b/db/migrations/20201021093209_add_media_file_indexes.go index f3a800949..7d6ad4965 100644 --- a/db/migrations/20201021093209_add_media_file_indexes.go +++ b/db/migrations/20201021093209_add_media_file_indexes.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201021093209, Down20201021093209) } -func Up20201021093209(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201021093209(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists media_file_artist on media_file (artist); create index if not exists media_file_album_artist @@ -23,6 +23,6 @@ create index if not exists media_file_mbz_track_id return err } -func Down20201021093209(_ context.Context, tx *sql.Tx) error { +func Down20201021093209(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201021135455_add_media_file_artist_index.go b/db/migrations/20201021135455_add_media_file_artist_index.go index ca04d8a20..e8f22c3a7 100644 --- a/db/migrations/20201021135455_add_media_file_artist_index.go +++ b/db/migrations/20201021135455_add_media_file_artist_index.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(Up20201021135455, Down20201021135455) } -func Up20201021135455(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201021135455(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists media_file_artist_id on media_file (artist_id); `) return err } -func Down20201021135455(_ context.Context, tx *sql.Tx) error { +func Down20201021135455(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201030162009_add_artist_info_table.go b/db/migrations/20201030162009_add_artist_info_table.go index f2917ae49..e33e15c23 100644 --- a/db/migrations/20201030162009_add_artist_info_table.go +++ b/db/migrations/20201030162009_add_artist_info_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl) } -func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddArtistImageUrl(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add biography varchar(255) default '' not null; alter table artist @@ -31,6 +31,6 @@ alter table artist return err } -func downAddArtistImageUrl(_ context.Context, tx *sql.Tx) error { +func downAddArtistImageUrl(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201110205344_add_comments_and_lyrics.go b/db/migrations/20201110205344_add_comments_and_lyrics.go index 5bb17b8d0..c60917bdd 100644 --- a/db/migrations/20201110205344_add_comments_and_lyrics.go +++ b/db/migrations/20201110205344_add_comments_and_lyrics.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201110205344, Down20201110205344) } -func Up20201110205344(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201110205344(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add comment varchar; alter table media_file @@ -24,10 +24,10 @@ alter table album if err != nil { return err } - notice(tx, "A full rescan will be performed to import comments and lyrics") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to import comments and lyrics") + return forceFullRescan(ctx, tx) } -func Down20201110205344(_ context.Context, tx *sql.Tx) error { +func Down20201110205344(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201128100726_add_real-path_option.go b/db/migrations/20201128100726_add_real-path_option.go index db102dfa9..4b3f62128 100644 --- a/db/migrations/20201128100726_add_real-path_option.go +++ b/db/migrations/20201128100726_add_real-path_option.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(Up20201128100726, Down20201128100726) } -func Up20201128100726(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201128100726(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table player add report_real_path bool default FALSE not null; `) return err } -func Down20201128100726(_ context.Context, tx *sql.Tx) error { +func Down20201128100726(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201213124814_add_all_artist_ids_to_album.go b/db/migrations/20201213124814_add_all_artist_ids_to_album.go index 170497f5c..81c30d611 100644 --- a/db/migrations/20201213124814_add_all_artist_ids_to_album.go +++ b/db/migrations/20201213124814_add_all_artist_ids_to_album.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(Up20201213124814, Down20201213124814) } -func Up20201213124814(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201213124814(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add all_artist_ids varchar; @@ -25,11 +25,11 @@ create index if not exists album_all_artist_ids return err } - return updateAlbums20201213124814(tx) + return updateAlbums20201213124814(ctx, tx) } -func updateAlbums20201213124814(tx *sql.Tx) error { - rows, err := tx.Query(` +func updateAlbums20201213124814(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, ` select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, ' ') from album a left join media_file mf on a.id = mf.album_id group by a.id `) @@ -59,6 +59,6 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, return rows.Err() } -func Down20201213124814(_ context.Context, tx *sql.Tx) error { +func Down20201213124814(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210322132848_add_timestamp_indexes.go b/db/migrations/20210322132848_add_timestamp_indexes.go index 3341dd3d2..5ed250fea 100644 --- a/db/migrations/20210322132848_add_timestamp_indexes.go +++ b/db/migrations/20210322132848_add_timestamp_indexes.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo) } -func upAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddTimestampIndexesGo(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists album_updated_at on album (updated_at); create index if not exists album_created_at @@ -29,6 +29,6 @@ create index if not exists media_file_updated_at return err } -func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error { +func downAddTimestampIndexesGo(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210418232815_fix_album_comments.go b/db/migrations/20210418232815_fix_album_comments.go index 59067640a..3c7ed86c1 100644 --- a/db/migrations/20210418232815_fix_album_comments.go +++ b/db/migrations/20210418232815_fix_album_comments.go @@ -14,10 +14,10 @@ func init() { goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments) } -func upFixAlbumComments(_ context.Context, tx *sql.Tx) error { +func upFixAlbumComments(ctx context.Context, tx *sql.Tx) error { //nolint:gosec - rows, err := tx.Query(` - SELECT album.id, group_concat(media_file.comment, '` + consts.Zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id; + rows, err := tx.QueryContext(ctx, ` + SELECT album.id, group_concat(media_file.comment, '`+consts.Zwsp+`') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id; `) if err != nil { return err @@ -49,7 +49,7 @@ func upFixAlbumComments(_ context.Context, tx *sql.Tx) error { return rows.Err() } -func downFixAlbumComments(_ context.Context, tx *sql.Tx) error { +func downFixAlbumComments(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210430212322_add_bpm_metadata.go b/db/migrations/20210430212322_add_bpm_metadata.go index 721c9e179..00a0f1447 100644 --- a/db/migrations/20210430212322_add_bpm_metadata.go +++ b/db/migrations/20210430212322_add_bpm_metadata.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata) } -func upAddBpmMetadata(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddBpmMetadata(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add bpm integer; @@ -22,10 +22,10 @@ create index if not exists media_file_bpm if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddBpmMetadata(_ context.Context, tx *sql.Tx) error { +func downAddBpmMetadata(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210530121921_create_shares_table.go b/db/migrations/20210530121921_create_shares_table.go index e9208bd69..d9e902a43 100644 --- a/db/migrations/20210530121921_create_shares_table.go +++ b/db/migrations/20210530121921_create_shares_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable) } -func upCreateSharesTable(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreateSharesTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table share ( id varchar(255) not null primary key, @@ -30,6 +30,6 @@ create table share return err } -func downCreateSharesTable(_ context.Context, tx *sql.Tx) error { +func downCreateSharesTable(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210601231734_update_share_fieldnames.go b/db/migrations/20210601231734_update_share_fieldnames.go index 965c0186e..5a459a34c 100644 --- a/db/migrations/20210601231734_update_share_fieldnames.go +++ b/db/migrations/20210601231734_update_share_fieldnames.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames) } -func upUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upUpdateShareFieldNames(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table share rename column expires to expires_at; alter table share rename column created to created_at; alter table share rename column last_visited to last_visited_at; @@ -21,6 +21,6 @@ alter table share rename column last_visited to last_visited_at; return err } -func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error { +func downUpdateShareFieldNames(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210616150710_encrypt_all_passwords.go b/db/migrations/20210616150710_encrypt_all_passwords.go index f67e3fb0a..dc8a9abd4 100644 --- a/db/migrations/20210616150710_encrypt_all_passwords.go +++ b/db/migrations/20210616150710_encrypt_all_passwords.go @@ -16,7 +16,7 @@ func init() { } func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.Query(`SELECT id, user_name, password from user;`) + rows, err := tx.QueryContext(ctx, `SELECT id, user_name, password from user;`) if err != nil { return err } @@ -51,6 +51,6 @@ func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { return rows.Err() } -func downEncodeAllPasswords(_ context.Context, tx *sql.Tx) error { +func downEncodeAllPasswords(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210619231716_drop_player_name_unique_constraint.go b/db/migrations/20210619231716_drop_player_name_unique_constraint.go index 200332156..734ffc340 100644 --- a/db/migrations/20210619231716_drop_player_name_unique_constraint.go +++ b/db/migrations/20210619231716_drop_player_name_unique_constraint.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint) } -func upDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upDropPlayerNameUniqueConstraint(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table player_dg_tmp ( id varchar(255) not null @@ -43,6 +43,6 @@ create index if not exists player_name return err } -func downDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error { +func downDropPlayerNameUniqueConstraint(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go index 5257dfab3..aa5e7a8f0 100644 --- a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go +++ b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go @@ -11,16 +11,16 @@ func init() { goose.AddMigrationContext(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled) } -func upAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error { - err := upAddUserPrefs(tx) +func upAddUserPrefsPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error { + err := upAddUserPrefs(ctx, tx) if err != nil { return err } - return upPlayerScrobblerEnabled(tx) + return upPlayerScrobblerEnabled(ctx, tx) } -func upAddUserPrefs(tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddUserPrefs(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table user_props ( user_id varchar not null, @@ -33,13 +33,13 @@ create table user_props return err } -func upPlayerScrobblerEnabled(tx *sql.Tx) error { - _, err := tx.Exec(` +func upPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table player add scrobble_enabled bool default true; `) return err } -func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error { +func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go index 033392d93..b2f93b4e3 100644 --- a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go +++ b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps) } -func upAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddReferentialIntegrityToUserProps(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table user_props_dg_tmp ( user_id varchar not null @@ -34,6 +34,6 @@ alter table user_props_dg_tmp rename to user_props; return err } -func downAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { +func downAddReferentialIntegrityToUserProps(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210626213026_add_scrobble_buffer.go b/db/migrations/20210626213026_add_scrobble_buffer.go index 1c4d0de2a..75d9d681c 100644 --- a/db/migrations/20210626213026_add_scrobble_buffer.go +++ b/db/migrations/20210626213026_add_scrobble_buffer.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddScrobbleBuffer, downAddScrobbleBuffer) } -func upAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddScrobbleBuffer(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table if not exists scrobble_buffer ( user_id varchar not null @@ -34,6 +34,6 @@ create table if not exists scrobble_buffer return err } -func downAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { +func downAddScrobbleBuffer(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210715151153_add_genre_tables.go b/db/migrations/20210715151153_add_genre_tables.go index ab2c54239..143f9c72b 100644 --- a/db/migrations/20210715151153_add_genre_tables.go +++ b/db/migrations/20210715151153_add_genre_tables.go @@ -11,9 +11,9 @@ func init() { goose.AddMigrationContext(upAddGenreTables, downAddGenreTables) } -func upAddGenreTables(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to import multiple genres!") - _, err := tx.Exec(` +func upAddGenreTables(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to import multiple genres!") + _, err := tx.ExecContext(ctx, ` create table if not exists genre ( id varchar not null primary key, @@ -61,9 +61,9 @@ create table if not exists artist_genres if err != nil { return err } - return forceFullRescan(tx) + return forceFullRescan(ctx, tx) } -func downAddGenreTables(_ context.Context, tx *sql.Tx) error { +func downAddGenreTables(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210821212604_add_mediafile_channels.go b/db/migrations/20210821212604_add_mediafile_channels.go index 9a0988b17..ee18be01b 100644 --- a/db/migrations/20210821212604_add_mediafile_channels.go +++ b/db/migrations/20210821212604_add_mediafile_channels.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddMediafileChannels, downAddMediafileChannels) } -func upAddMediafileChannels(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddMediafileChannels(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add channels integer; @@ -22,10 +22,10 @@ create index if not exists media_file_channels if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddMediafileChannels(_ context.Context, tx *sql.Tx) error { +func downAddMediafileChannels(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211008205505_add_smart_playlist.go b/db/migrations/20211008205505_add_smart_playlist.go index c8ed67c47..0d2d1ad4e 100644 --- a/db/migrations/20211008205505_add_smart_playlist.go +++ b/db/migrations/20211008205505_add_smart_playlist.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddSmartPlaylist, downAddSmartPlaylist) } -func upAddSmartPlaylist(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddSmartPlaylist(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table playlist add column rules varchar null; alter table playlist @@ -33,6 +33,6 @@ create unique index playlist_fields_idx return err } -func downAddSmartPlaylist(_ context.Context, tx *sql.Tx) error { +func downAddSmartPlaylist(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211023184825_add_order_title_to_media_file.go b/db/migrations/20211023184825_add_order_title_to_media_file.go index ee6fc67d1..4a2ae4047 100644 --- a/db/migrations/20211023184825_add_order_title_to_media_file.go +++ b/db/migrations/20211023184825_add_order_title_to_media_file.go @@ -14,8 +14,8 @@ func init() { goose.AddMigrationContext(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile) } -func upAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddOrderTitleToMediaFile(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table main.media_file add order_title varchar null collate NOCASE; create index if not exists media_file_order_title @@ -25,12 +25,12 @@ create index if not exists media_file_order_title return err } - return upAddOrderTitleToMediaFile_populateOrderTitle(tx) + return upAddOrderTitleToMediaFile_populateOrderTitle(ctx, tx) } //goland:noinspection GoSnakeCaseUsage -func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error { - rows, err := tx.Query(`select id, title from media_file`) +func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, title from media_file`) if err != nil { return err } @@ -57,6 +57,6 @@ func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error { return rows.Err() } -func downAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error { +func downAddOrderTitleToMediaFile(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211026191915_unescape_lyrics_and_comments.go b/db/migrations/20211026191915_unescape_lyrics_and_comments.go index d4ba5e194..a7969ffed 100644 --- a/db/migrations/20211026191915_unescape_lyrics_and_comments.go +++ b/db/migrations/20211026191915_unescape_lyrics_and_comments.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments) } -func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { - rows, err := tx.Query(`select id, comment, lyrics, title from media_file`) +func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, comment, lyrics, title from media_file`) if err != nil { return err } @@ -43,6 +43,6 @@ func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { return rows.Err() } -func downUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { +func downUnescapeLyricsAndComments(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211029213200_add_userid_to_playlist.go b/db/migrations/20211029213200_add_userid_to_playlist.go index e262fc205..909ea1c54 100644 --- a/db/migrations/20211029213200_add_userid_to_playlist.go +++ b/db/migrations/20211029213200_add_userid_to_playlist.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddUseridToPlaylist, downAddUseridToPlaylist) } -func upAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddUseridToPlaylist(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playlist_dg_tmp ( id varchar(255) not null @@ -56,6 +56,6 @@ create index playlist_updated_at return err } -func downAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error { +func downAddUseridToPlaylist(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go index 4ab4305d0..f786b69e7 100644 --- a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go +++ b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex) } -func upAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddAlphabeticalByArtistIndex(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index album_alphabetical_by_artist ON album(compilation, order_album_artist_name, order_album_name) `) return err } -func downAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error { +func downAddAlphabeticalByArtistIndex(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211105162746_remove_invalid_artist_ids.go b/db/migrations/20211105162746_remove_invalid_artist_ids.go index 5e078c820..8c9887dd1 100644 --- a/db/migrations/20211105162746_remove_invalid_artist_ids.go +++ b/db/migrations/20211105162746_remove_invalid_artist_ids.go @@ -11,13 +11,13 @@ func init() { goose.AddMigrationContext(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds) } -func upRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRemoveInvalidArtistIds(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id) `) return err } -func downRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error { +func downRemoveInvalidArtistIds(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go index 481762117..42e13a1e5 100644 --- a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go +++ b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go @@ -11,19 +11,19 @@ func init() { goose.AddMigrationContext(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId) } -func upAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add mbz_release_track_id varchar(255); `) if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error { +func downAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20221219112733_add_album_image_paths.go b/db/migrations/20221219112733_add_album_image_paths.go index ee9c77c8a..f8ebd40e9 100644 --- a/db/migrations/20221219112733_add_album_image_paths.go +++ b/db/migrations/20221219112733_add_album_image_paths.go @@ -11,17 +11,17 @@ func init() { goose.AddMigrationContext(upAddAlbumImagePaths, downAddAlbumImagePaths) } -func upAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddAlbumImagePaths(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table main.album add image_files varchar; `) if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(ctx, tx) } -func downAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error { +func downAddAlbumImagePaths(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20221219140528_remove_cover_art_id.go b/db/migrations/20221219140528_remove_cover_art_id.go index a1eaa89f9..30f86297a 100644 --- a/db/migrations/20221219140528_remove_cover_art_id.go +++ b/db/migrations/20221219140528_remove_cover_art_id.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(upRemoveCoverArtId, downRemoveCoverArtId) } -func upRemoveCoverArtId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRemoveCoverArtId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album drop column cover_art_id; alter table album rename column cover_art_path to embed_art_path `) if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(ctx, tx) } -func downRemoveCoverArtId(_ context.Context, tx *sql.Tx) error { +func downRemoveCoverArtId(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230112111457_add_album_paths.go b/db/migrations/20230112111457_add_album_paths.go index 2dfb9a747..2819522a1 100644 --- a/db/migrations/20230112111457_add_album_paths.go +++ b/db/migrations/20230112111457_add_album_paths.go @@ -16,15 +16,15 @@ func init() { goose.AddMigrationContext(upAddAlbumPaths, downAddAlbumPaths) } -func upAddAlbumPaths(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`alter table album add paths varchar;`) +func upAddAlbumPaths(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `alter table album add paths varchar;`) if err != nil { return err } //nolint:gosec - rows, err := tx.Query(` - select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id + rows, err := tx.QueryContext(ctx, ` + select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id `) if err != nil { return err @@ -63,6 +63,6 @@ func upAddAlbumPathsDirs(filePaths string) string { return strings.Join(dirs, string(filepath.ListSeparator)) } -func downAddAlbumPaths(_ context.Context, tx *sql.Tx) error { +func downAddAlbumPaths(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230114121537_touch_playlists.go b/db/migrations/20230114121537_touch_playlists.go index 0f10e275c..71959b0a8 100644 --- a/db/migrations/20230114121537_touch_playlists.go +++ b/db/migrations/20230114121537_touch_playlists.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(upTouchPlaylists, downTouchPlaylists) } -func upTouchPlaylists(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`update playlist set updated_at = datetime('now');`) +func upTouchPlaylists(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `update playlist set updated_at = datetime('now');`) return err } -func downTouchPlaylists(_ context.Context, tx *sql.Tx) error { +func downTouchPlaylists(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230115103212_create_internet_radio.go b/db/migrations/20230115103212_create_internet_radio.go index 5c014dac2..3e0da348f 100644 --- a/db/migrations/20230115103212_create_internet_radio.go +++ b/db/migrations/20230115103212_create_internet_radio.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreateInternetRadio, downCreateInternetRadio) } -func upCreateInternetRadio(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreateInternetRadio(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table if not exists radio ( id varchar(255) not null primary key, @@ -26,6 +26,6 @@ create table if not exists radio return err } -func downCreateInternetRadio(_ context.Context, tx *sql.Tx) error { +func downCreateInternetRadio(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230117155559_add_replaygain_metadata.go b/db/migrations/20230117155559_add_replaygain_metadata.go index d6be3b313..3aad70925 100644 --- a/db/migrations/20230117155559_add_replaygain_metadata.go +++ b/db/migrations/20230117155559_add_replaygain_metadata.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddReplaygainMetadata, downAddReplaygainMetadata) } -func upAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddReplaygainMetadata(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add rg_album_gain real; alter table media_file add @@ -26,10 +26,10 @@ alter table media_file add return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error { +func downAddReplaygainMetadata(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230117180400_add_album_info.go b/db/migrations/20230117180400_add_album_info.go index 5d6dd8230..750d3838f 100644 --- a/db/migrations/20230117180400_add_album_info.go +++ b/db/migrations/20230117180400_add_album_info.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddAlbumInfo, downAddAlbumInfo) } -func upAddAlbumInfo(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddAlbumInfo(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add description varchar(255) default '' not null; alter table album @@ -29,6 +29,6 @@ alter table album return err } -func downAddAlbumInfo(_ context.Context, tx *sql.Tx) error { +func downAddAlbumInfo(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230119152657_recreate_share_table.go b/db/migrations/20230119152657_recreate_share_table.go index e1ae816c0..10eff31ca 100644 --- a/db/migrations/20230119152657_recreate_share_table.go +++ b/db/migrations/20230119152657_recreate_share_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddMissingShareInfo, downAddMissingShareInfo) } -func upAddMissingShareInfo(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddMissingShareInfo(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` drop table if exists share; create table share ( @@ -37,6 +37,6 @@ create table share return err } -func downAddMissingShareInfo(_ context.Context, tx *sql.Tx) error { +func downAddMissingShareInfo(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230202143713_change_path_list_separator.go b/db/migrations/20230202143713_change_path_list_separator.go index 78b030ae4..fb5f2be1a 100644 --- a/db/migrations/20230202143713_change_path_list_separator.go +++ b/db/migrations/20230202143713_change_path_list_separator.go @@ -16,10 +16,10 @@ func init() { goose.AddMigrationContext(upChangePathListSeparator, downChangePathListSeparator) } -func upChangePathListSeparator(_ context.Context, tx *sql.Tx) error { +func upChangePathListSeparator(ctx context.Context, tx *sql.Tx) error { //nolint:gosec - rows, err := tx.Query(` - select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id + rows, err := tx.QueryContext(ctx, ` + select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id `) if err != nil { return err @@ -58,6 +58,6 @@ func upChangePathListSeparatorDirs(filePaths string) string { return strings.Join(dirs, consts.Zwsp) } -func downChangePathListSeparator(_ context.Context, tx *sql.Tx) error { +func downChangePathListSeparator(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230209181414_change_image_files_list_separator.go b/db/migrations/20230209181414_change_image_files_list_separator.go index 7f4d4cb0e..e5dc4ab43 100644 --- a/db/migrations/20230209181414_change_image_files_list_separator.go +++ b/db/migrations/20230209181414_change_image_files_list_separator.go @@ -16,8 +16,8 @@ func init() { goose.AddMigrationContext(upChangeImageFilesListSeparator, downChangeImageFilesListSeparator) } -func upChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error { - rows, err := tx.Query(`select id, image_files from album`) +func upChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, image_files from album`) if err != nil { return err } @@ -54,7 +54,7 @@ func upChangeImageFilesListSeparatorDirs(filePaths string) string { return strings.Join(allPaths, consts.Zwsp) } -func downChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error { +func downChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20230310222612_add_download_to_share.go b/db/migrations/20230310222612_add_download_to_share.go index ed2879ec3..3ee24cc77 100644 --- a/db/migrations/20230310222612_add_download_to_share.go +++ b/db/migrations/20230310222612_add_download_to_share.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(upAddDownloadToShare, downAddDownloadToShare) } -func upAddDownloadToShare(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddDownloadToShare(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table share add downloadable bool not null default false; `) return err } -func downAddDownloadToShare(_ context.Context, tx *sql.Tx) error { +func downAddDownloadToShare(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230515184510_add_release_date.go b/db/migrations/20230515184510_add_release_date.go index 1141a1e74..f22bdfae8 100644 --- a/db/migrations/20230515184510_add_release_date.go +++ b/db/migrations/20230515184510_add_release_date.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddRelRecYear, downAddRelRecYear) } -func upAddRelRecYear(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddRelRecYear(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add date varchar(255) default '' not null; alter table media_file @@ -41,10 +41,10 @@ alter table album return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddRelRecYear(_ context.Context, tx *sql.Tx) error { +func downAddRelRecYear(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go index 170fc264c..562a59bb5 100644 --- a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go +++ b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go @@ -11,16 +11,16 @@ func init() { goose.AddMigrationContext(upRenameMusicbrainzRecordingId, downRenameMusicbrainzRecordingId) } -func upRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file rename column mbz_track_id to mbz_recording_id; `) return err } -func downRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func downRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file rename column mbz_recording_id to mbz_track_id; `) diff --git a/db/migrations/20231209211223_alter_lyric_column.go b/db/migrations/20231209211223_alter_lyric_column.go index ac73fc98f..259a37745 100644 --- a/db/migrations/20231209211223_alter_lyric_column.go +++ b/db/migrations/20231209211223_alter_lyric_column.go @@ -29,7 +29,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { return err } - rows, err := tx.Query(`select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) + rows, err := tx.QueryContext(ctx, `select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) if err != nil { return err } @@ -46,12 +46,12 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { continue } - lyrics, err := model.ToLyrics("xxx", lyrics.String) + parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(lyrics.String)) if err != nil { return err } - text, err := json.Marshal(model.LyricList{*lyrics}) + text, err := json.Marshal(parsed) if err != nil { return err } @@ -72,7 +72,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { return err } - notice(tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") + notice(ctx, tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") return nil } diff --git a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go index a65b0aefd..518d125e4 100644 --- a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go +++ b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go @@ -558,6 +558,6 @@ create index media_file_mbz_track_id return err } -func Down20240122223340(context.Context, *sql.Tx) error { +func Down20240122223340(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20240511210036_add_sample_rate.go b/db/migrations/20240511210036_add_sample_rate.go index 619cdcffd..76b809c36 100644 --- a/db/migrations/20240511210036_add_sample_rate.go +++ b/db/migrations/20240511210036_add_sample_rate.go @@ -19,7 +19,7 @@ alter table media_file create index if not exists media_file_sample_rate on media_file (sample_rate); `) - notice(tx, "A full rescan should be performed to pick up additional tags") + notice(ctx, tx, "A full rescan should be performed to pick up additional tags") return err } diff --git a/db/migrations/20240629152843_remove_annotation_id.go b/db/migrations/20240629152843_remove_annotation_id.go index b450b26d4..972932e10 100644 --- a/db/migrations/20240629152843_remove_annotation_id.go +++ b/db/migrations/20240629152843_remove_annotation_id.go @@ -61,6 +61,6 @@ create index annotation_starred_at return err } -func downRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error { +func downRemoveAnnotationId(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20241026183640_support_new_scanner.go b/db/migrations/20241026183640_support_new_scanner.go index fcbef7e4e..f5899f08f 100644 --- a/db/migrations/20241026183640_support_new_scanner.go +++ b/db/migrations/20241026183640_support_new_scanner.go @@ -97,8 +97,8 @@ insert into property (id, value) values ('PIDTrack', 'track_legacy') on conflict insert into property (id, value) values ('PIDAlbum', 'album_legacy') on conflict do nothing; `), func() error { - notice(tx, "A full scan will be triggered to populate the new tables. This may take a while.") - return forceFullRescan(tx) + notice(ctx, tx, "A full scan will be triggered to populate the new tables. This may take a while.") + return forceFullRescan(ctx, tx) }, ) } @@ -314,6 +314,6 @@ alter table artist } } -func downSupportNewScanner(context.Context, *sql.Tx) error { +func downSupportNewScanner(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250611010101_playqueue_current_to_index.go b/db/migrations/20250611010101_playqueue_current_to_index.go index d9250eba2..1b83c0b35 100644 --- a/db/migrations/20250611010101_playqueue_current_to_index.go +++ b/db/migrations/20250611010101_playqueue_current_to_index.go @@ -75,6 +75,6 @@ create table playqueue_dg_tmp( return err } -func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error { +func downPlayQueueCurrentToIndex(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go index e82a0749f..c350d31f5 100644 --- a/db/migrations/20250701010101_add_folder_hash.go +++ b/db/migrations/20250701010101_add_folder_hash.go @@ -16,6 +16,6 @@ func upAddFolderHash(ctx context.Context, tx *sql.Tx) error { return err } -func downAddFolderHash(ctx context.Context, tx *sql.Tx) error { +func downAddFolderHash(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010103_add_library_stats.go b/db/migrations/20250701010103_add_library_stats.go index 8025229cc..a84758a04 100644 --- a/db/migrations/20250701010103_add_library_stats.go +++ b/db/migrations/20250701010103_add_library_stats.go @@ -43,6 +43,6 @@ update library set return err } -func downAddLibraryStats(ctx context.Context, tx *sql.Tx) error { +func downAddLibraryStats(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010104_make_replaygain_fields_nullable.go b/db/migrations/20250701010104_make_replaygain_fields_nullable.go index 163608d32..c6beb2a51 100644 --- a/db/migrations/20250701010104_make_replaygain_fields_nullable.go +++ b/db/migrations/20250701010104_make_replaygain_fields_nullable.go @@ -39,7 +39,7 @@ ALTER TABLE media_file RENAME COLUMN rg_track_peak_new TO rg_track_peak; return err } - notice(tx, "Fetching replaygain fields properly will require a full scan") + notice(ctx, tx, "Fetching replaygain fields properly will require a full scan") return nil } diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go index dc4cd647b..6f2bde429 100644 --- a/db/migrations/20260220173400_add_fts5_search.go +++ b/db/migrations/20260220173400_add_fts5_search.go @@ -22,7 +22,7 @@ func stripPunct(col string) string { } func upAddFts5Search(ctx context.Context, tx *sql.Tx) error { - notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") + notice(ctx, tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") // Step 1: Add search_participants and search_normalized columns to media_file, album, and artist _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`) diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go index 4e8b1b7f5..f52f48440 100644 --- a/db/migrations/20260307175815_add_codec_and_update_transcodings.go +++ b/db/migrations/20260307175815_add_codec_and_update_transcodings.go @@ -12,20 +12,20 @@ func init() { goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings) } -func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { +func upAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { // Add codec column to media_file. - _, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) if err != nil { return err } - _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) + _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) if err != nil { return err } // Update old AAC default (adts) to new default (ipod with fragmented MP4). // Only affects users who still have the unmodified old default command. - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`, "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", @@ -36,12 +36,12 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { // Add FLAC transcoding for existing installations that were seeded before FLAC was added. var count int - err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) + err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) if err != nil { return err } if count == 0 { - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", id.NewRandom(), "flac audio", "flac", 0, "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", @@ -52,22 +52,22 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { } // Add probe_data column for caching ffprobe results. - _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) if err != nil { return err } return nil } -func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) +func downAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`) + _, err = tx.ExecContext(ctx, `DROP INDEX IF EXISTS media_file_codec`) if err != nil { return err } - _, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN codec`) return err } diff --git a/db/migrations/20260309120007_fix_probe_data_null.go b/db/migrations/20260309120007_fix_probe_data_null.go index a7e7366ed..c76d6ed1a 100644 --- a/db/migrations/20260309120007_fix_probe_data_null.go +++ b/db/migrations/20260309120007_fix_probe_data_null.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull) } -func upFixProbeDataNull(_ context.Context, tx *sql.Tx) error { +func upFixProbeDataNull(ctx context.Context, tx *sql.Tx) error { // Recreate probe_data column as NOT NULL with empty string default. // The previous migration created it with DEFAULT NULL, which causes // scan errors when reading into Go string fields. - _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) return err } -func downFixProbeDataNull(_ context.Context, tx *sql.Tx) error { +func downFixProbeDataNull(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go index ab6d24952..4df66d26d 100644 --- a/db/migrations/20260309203355_ensure_default_transcodings.go +++ b/db/migrations/20260309203355_ensure_default_transcodings.go @@ -13,7 +13,7 @@ func init() { goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings) } -func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func upEnsureDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { // Older installations may be missing default transcodings that were added // after the initial seeding (e.g., aac was added later than mp3/opus). // Insert any missing defaults without touching user-customized entries. @@ -22,12 +22,12 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { // but the same name. for _, t := range consts.DefaultTranscodings { var count int - err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) + err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) if err != nil { return err } if count == 0 { - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command, ) @@ -39,6 +39,6 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { return nil } -func downEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func downEnsureDefaultTranscodings(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go index 588137383..a4fa8fcc1 100644 --- a/db/migrations/20260310113858_fix_aac_transcode_command.go +++ b/db/migrations/20260310113858_fix_aac_transcode_command.go @@ -11,20 +11,20 @@ func init() { goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand) } -func upFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { +func upFixAacTranscodeCommand(ctx context.Context, tx *sql.Tx) error { // The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces // corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+). // Switch to `-f adts` (raw AAC framing) which works reliably via pipe. // Only update rows that still have the old default command. const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -" const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -" - _, err := tx.Exec( + _, err := tx.ExecContext(ctx, "UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?", newCommand, oldCommand, ) return err } -func downFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { +func downFixAacTranscodeCommand(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go index c16583aa0..472ae7b43 100644 --- a/db/migrations/20260513173954_move_ss_before_input.go +++ b/db/migrations/20260513173954_move_ss_before_input.go @@ -36,18 +36,18 @@ var ssSeekPairs = [][2]string{ }, } -func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { +func upMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { for _, p := range ssSeekPairs { - if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { return err } } return nil } -func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { +func downMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { for _, p := range ssSeekPairs { - if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { return err } } diff --git a/db/migrations/20260618120509_add_metadata_to_default_transcodings.go b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go new file mode 100644 index 000000000..2186cda91 --- /dev/null +++ b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go @@ -0,0 +1,64 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddMetadataToDefaultTranscodings, downAddMetadataToDefaultTranscodings) +} + +// metadataPairs maps the current default commands (no metadata mapping) to the +// new defaults that preserve source tags. Index 0 = old, index 1 = new. +// +// The new commands add `-map_metadata 0 -map_metadata 0:s:a:0` after `-map 0:a:0`: +// `-map_metadata 0` copies format-level tags (MP3/FLAC sources) and +// `-map_metadata 0:s:a:0` copies tags from the first audio stream (OPUS/OGG +// sources); both are needed because the two source families store tags at +// different levels. Targeting the audio stream explicitly avoids pulling +// metadata from an embedded cover-art/video stream at index 0. +// +// AAC is included for consistency, but its `-f adts` container cannot hold tags, +// so the flags are a no-op there. +// +// Only rows still holding the exact unmodified default are updated, so any +// user-customized command is left untouched. +var metadataPairs = [][2]string{ + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", + }, +} + +func upAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + for _, p := range metadataPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + return err + } + } + return nil +} + +func downAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + for _, p := range metadataPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + return err + } + } + return nil +} diff --git a/db/migrations/20260629123100_recently_added_plain_indexes.sql b/db/migrations/20260629123100_recently_added_plain_indexes.sql new file mode 100644 index 000000000..731959e62 --- /dev/null +++ b/db/migrations/20260629123100_recently_added_plain_indexes.sql @@ -0,0 +1,28 @@ +-- +goose Up + +-- The "Recently Added" sort now uses the raw timestamp with an id tiebreak +-- instead of datetime(), so the indexes become plain composite (col, id) to +-- cover it. Timestamps were already normalized to space-format by +-- 20260316000000_normalize_timestamps, so raw-string comparison is safe. + +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(created_at, id); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(updated_at, id); + +DROP INDEX IF EXISTS media_file_created_at; +CREATE INDEX media_file_created_at ON media_file(created_at, id); +DROP INDEX IF EXISTS media_file_updated_at; +CREATE INDEX media_file_updated_at ON media_file(updated_at, id); + +-- +goose Down + +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(datetime(created_at)); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(datetime(updated_at)); + +DROP INDEX IF EXISTS media_file_created_at; +CREATE INDEX media_file_created_at ON media_file(created_at); +DROP INDEX IF EXISTS media_file_updated_at; +CREATE INDEX media_file_updated_at ON media_file(updated_at); diff --git a/db/migrations/migration.go b/db/migrations/migration.go index fde6f5817..9b1098af1 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -12,23 +12,23 @@ import ( ) // Use this in migrations that need to communicate something important (breaking changes, forced reindexes, etc...) -func notice(tx *sql.Tx, msg string) { - if isDBInitialized(tx) { +func notice(ctx context.Context, tx *sql.Tx, msg string) { + if isDBInitialized(ctx, tx) { line := strings.Repeat("*", len(msg)+8) fmt.Printf("\n%s\nNOTICE: %s\n%s\n\n", line, msg, line) } } // Call this in migrations that requires a full rescan -func forceFullRescan(tx *sql.Tx) error { +func forceFullRescan(ctx context.Context, tx *sql.Tx) error { // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. if conf.Server.DevOptimizeDB { - _, err := tx.Exec(`ANALYZE;`) + _, err := tx.ExecContext(ctx, `ANALYZE;`) if err != nil { return err } } - _, err := tx.Exec(fmt.Sprintf(` + _, err := tx.ExecContext(ctx, fmt.Sprintf(` INSERT OR REPLACE into property (id, value) values ('%s', '1'); `, consts.FullScanAfterMigrationFlagKey)) return err @@ -44,9 +44,9 @@ var ( initialized bool ) -func isDBInitialized(tx *sql.Tx) bool { +func isDBInitialized(ctx context.Context, tx *sql.Tx) bool { once.Do(func() { - rows, err := tx.Query("select count(*) from property where id=?", consts.InitialSetupFlagKey) + rows, err := tx.QueryContext(ctx, "select count(*) from property where id=?", consts.InitialSetupFlagKey) checkErr(err) initialized = checkCount(rows) > 0 }) diff --git a/go.mod b/go.mod index 29a415126..4803c23ee 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d require ( github.com/Masterminds/squirrel v1.5.4 - github.com/andybalholm/cascadia v1.3.3 + github.com/andybalholm/cascadia v1.3.4 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 @@ -19,7 +19,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 - github.com/gen2brain/webp v0.5.5 + github.com/gen2brain/webp v0.6.3 github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 @@ -32,16 +32,16 @@ require ( github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 - github.com/jellydator/ttlcache/v3 v3.4.0 + github.com/jellydator/ttlcache/v3 v3.4.1 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.44 + github.com/mattn/go-sqlite3 v1.14.47 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.29.0 - github.com/onsi/gomega v1.41.0 - github.com/pelletier/go-toml/v2 v2.3.1 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 + github.com/pelletier/go-toml/v2 v2.4.2 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.1 @@ -54,17 +54,17 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 + github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.41.0 - golang.org/x/net v0.55.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.45.0 - golang.org/x/term v0.43.0 - golang.org/x/text v0.37.0 + golang.org/x/image v0.43.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 + golang.org/x/text v0.38.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -89,7 +89,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect + github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -104,7 +104,7 @@ require ( github.com/lestrrat-go/dsig v1.3.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/maruel/natural v1.3.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -133,12 +133,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/ini.v1 v1.67.2 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 57289abfd..1972e34c2 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= +github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -32,8 +32,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw= -github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4= +github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs= +github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= @@ -65,8 +65,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= -github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= +github.com/gen2brain/webp v0.6.3 h1:DbXXCkiHN6zq2qIuTsPSZQhVi2VcQ0UPzKbKqKENfsQ= +github.com/gen2brain/webp v0.6.3/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -101,13 +101,12 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -129,8 +128,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QS github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= -github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= @@ -165,8 +164,8 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7 github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= -github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= @@ -175,8 +174,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= +github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -193,12 +192,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= -github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= -github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q= +github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -279,8 +278,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= -github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg= -github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -295,7 +294,6 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= @@ -311,106 +309,47 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= -golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= -gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/model/criteria/json.go b/model/criteria/json.go index ca47ceb95..beded9d1f 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -96,20 +96,32 @@ func normalizeBoolFields(m map[string]any) { } } -func normalizeBoolValue(v any) any { +// ToBool coerces a criteria value to a bool, accepting the forms criteria values take: a real bool, +// a strconv.ParseBool-parseable string, or a JSON number that is exactly 0 or 1. Any other value +// (other numbers, slices, nil, unparseable strings) returns ok=false so callers can handle it. +func ToBool(v any) (bool, bool) { switch val := v.(type) { + case bool: + return val, true case string: - if b, err := strconv.ParseBool(val); err == nil { - return b - } + b, err := strconv.ParseBool(val) + return b, err == nil case float64: - if val == 1 { - return true - } - if val == 0 { - return false + switch val { + case 1: + return true, true + case 0: + return false, true } } + return false, false +} + +// normalizeBoolValue leaves non-boolean values unchanged so they flow through to their own validation. +func normalizeBoolValue(v any) any { + if b, ok := ToBool(v); ok { + return b + } return v } diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 17c4272ba..e5c8e1763 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -126,4 +126,25 @@ var _ = Describe("Operators", func() { gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true})) }) }) + + DescribeTable("ToBool", + func(in any, wantVal, wantOk bool) { + got, ok := ToBool(in) + gomega.Expect(ok).To(gomega.Equal(wantOk)) + gomega.Expect(got).To(gomega.Equal(wantVal)) + }, + Entry("real bool true", true, true, true), + Entry("real bool false", false, false, true), + Entry("string true", "true", true, true), + Entry("string false", "false", false, true), + Entry("string 1", "1", true, true), + Entry("string t", "t", true, true), + Entry("string 0", "0", false, true), + Entry("string unparseable", "yes", false, false), + Entry("float64 1", float64(1), true, true), + Entry("float64 0", float64(0), false, true), + Entry("float64 other", float64(2), false, false), + Entry("slice", []any{true}, false, false), + Entry("nil", nil, false, false), + ) }) diff --git a/model/lyrics.go b/model/lyrics.go index f75f3b11b..111b1c2a9 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -1,229 +1,93 @@ package model import ( - "cmp" - "regexp" - "slices" - "strconv" + "encoding/json" "strings" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/str" ) +type Cue struct { + Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` + Value string `structs:"value" json:"value"` + ByteStart int `structs:"byteStart" json:"byteStart"` + ByteEnd int `structs:"byteEnd" json:"byteEnd"` + AgentID string `structs:"agentId,omitempty" json:"agentId,omitempty"` +} + +type Agent struct { + ID string `structs:"id" json:"id"` + Role string `structs:"role" json:"role"` + Name string `structs:"name,omitempty" json:"name,omitempty"` +} + type Line struct { Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` Value string `structs:"value" json:"value"` + Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"` } type Lyrics struct { - DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` - Lang string `structs:"lang" json:"lang"` - Line []Line `structs:"line" json:"line"` - Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` - Synced bool `structs:"synced" json:"synced"` + DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` + Kind string `structs:"kind,omitempty" json:"kind,omitempty"` + Lang string `structs:"lang" json:"lang"` + Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"` + Line []Line `structs:"line" json:"line"` + Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` + Synced bool `structs:"synced" json:"synced"` } -// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] -const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?\]` - -var ( - // Should either be at the beginning of file, or beginning of line - syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) - timeRegex = regexp.MustCompile(timeRegexString) - lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) +// Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are +// the canonical wire values; keep them in sync with the spec. +const ( + LyricKindMain = "main" + LyricKindTranslation = "translation" + LyricKindPronunciation = "pronunciation" ) func (l Lyrics) IsEmpty() bool { return len(l.Line) == 0 } -func ToLyrics(language, text string) (*Lyrics, error) { - text = str.SanitizeText(text) - - lines := strings.Split(text, "\n") - structuredLines := make([]Line, 0, len(lines)*2) - - artist := "" - title := "" - var offset *int64 = nil - - synced := syncRegex.MatchString(text) - priorLine := "" - validLine := false - repeated := false - var timestamps []int64 - - for _, line := range lines { - line := strings.TrimSpace(line) - if line == "" { - if validLine { - priorLine += "\n" - } - continue - } - var text string - var time *int64 = nil - - if synced { - idTag := lrcIdRegex.FindStringSubmatch(line) - if idTag != nil { - switch idTag[1] { - case "ar": - artist = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "lang": - language = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "offset": - { - off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) - if err != nil { - log.Warn("Error parsing offset", "offset", idTag[2], "error", err) - } else { - offset = &off - } - } - case "ti": - title = str.SanitizeText(strings.TrimSpace(idTag[2])) - } - - continue - } - - times := timeRegex.FindAllStringSubmatchIndex(line, -1) - if len(times) > 1 { - repeated = true - } - - // The second condition is for when there is a timestamp in the middle of - // a line (after any text) - if times == nil || times[0][0] != 0 { - if validLine { - priorLine += "\n" + line - } - continue - } - - if validLine { - for idx := range timestamps { - structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), - }) - } - timestamps = nil - } - - end := 0 - - // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] - for _, match := range times { - // for multiple matches, we need to check that later matches are not - // in the middle of the string - if end != 0 { - middle := strings.TrimSpace(line[end:match[0]]) - if middle != "" { - break - } - } - - end = match[1] - timeInMillis, err := parseTime(line, match) - if err != nil { - return nil, err - } - - timestamps = append(timestamps, timeInMillis) - } - - if end >= len(line) { - priorLine = "" - } else { - priorLine = strings.TrimSpace(line[end:]) - } - - validLine = true - } else { - text = line - structuredLines = append(structuredLines, Line{ - Start: time, - Value: text, - }) - } - } - - if validLine { - for idx := range timestamps { - structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), - }) - } - } - - // If there are repeated values, there is no guarantee that they are in order - // In this, case, sort the lyrics by start time - if repeated { - slices.SortFunc(structuredLines, func(a, b Line) int { - return cmp.Compare(*a.Start, *b.Start) - }) - } - - lyrics := Lyrics{ - DisplayArtist: artist, - DisplayTitle: title, - Lang: language, - Line: structuredLines, - Offset: offset, - Synced: synced, - } - return &lyrics, nil +// IsMainKind reports whether the lyric is the main track. A blank kind is an +// untyped (single-track) lyric, which the contract treats as main. +func (l Lyrics) IsMainKind() bool { + return l.EffectiveKind() == LyricKindMain } -func parseTime(line string, match []int) (int64, error) { - var hours, millis int64 - var err error - - hourStart := match[2] - if hourStart != -1 { - // subtract 1 because group has : at the end - hourEnd := match[3] - 1 - hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) - if err != nil { - return 0, err - } +// EffectiveKind returns the lyric kind, defaulting to LyricKindMain when blank. +// A blank kind means an untyped (single-track) lyric, which the contract treats +// as main. +func (l Lyrics) EffectiveKind() string { + if strings.TrimSpace(l.Kind) == "" { + return LyricKindMain } - - minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) - if err != nil { - return 0, err - } - - sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) - if err != nil { - return 0, err - } - - msStart := match[8] - if msStart != -1 { - msEnd := match[9] - // +1 offset since this capture group contains . - millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) - if err != nil { - return 0, err - } - - length := msEnd - msStart - - if length == 3 { - millis *= 10 - } else if length == 2 { - millis *= 100 - } - } - - timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis - return timeInMillis, nil + return l.Kind } type LyricList []Lyrics + +// MarshalJSON keeps the lyrics column invariant: empty/nil serializes to [], never null. +func (ll LyricList) MarshalJSON() ([]byte, error) { + if len(ll) == 0 { + return []byte("[]"), nil + } + return json.Marshal([]Lyrics(ll)) +} + +// Main returns the main-kind lyric, falling back to the first entry so untyped +// lyrics still resolve. The bool is false only when the list is empty. It is +// used to surface a single lyric through the plain-text legacy getLyrics +// endpoint, which has no notion of translation/pronunciation tracks. +func (ll LyricList) Main() (Lyrics, bool) { + if len(ll) == 0 { + return Lyrics{}, false + } + for _, l := range ll { + if l.IsMainKind() { + return l, true + } + } + return ll[0], true +} diff --git a/model/lyrics_benchmark_test.go b/model/lyrics_benchmark_test.go new file mode 100644 index 000000000..0de2549e7 --- /dev/null +++ b/model/lyrics_benchmark_test.go @@ -0,0 +1,45 @@ +package model + +import ( + "os" + "path/filepath" + "testing" +) + +// Benchmark payloads are real public-domain lyrics ("Auld Lang Syne", Robert +// Burns, 1788) rendered into every supported format, so the numbers reflect +// realistic content and sizing. The same song across formats makes per-format +// cost directly comparable. Fixtures live in tests/fixtures/lyrics/. +func loadLyricFixture(b *testing.B, name string) []byte { + b.Helper() + contents, err := os.ReadFile(filepath.Join("..", "tests", "fixtures", "lyrics", name)) + if err != nil { + b.Fatal(err) + } + return contents +} + +func benchmarkParse(b *testing.B, suffix, fixture string) { + contents := loadLyricFixture(b, fixture) + b.ReportAllocs() + b.SetBytes(int64(len(contents))) + for b.Loop() { + if _, err := ParseLyrics(suffix, "eng", contents); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkParseLyrics_LRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_Plain(b *testing.B) { benchmarkParse(b, ".txt", "auld-lang-syne.txt") } +func BenchmarkParseLyrics_EnhancedLRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.elrc") } +func BenchmarkParseLyrics_SRT(b *testing.B) { benchmarkParse(b, ".srt", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_TTML(b *testing.B) { benchmarkParse(b, ".ttml", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_YAML(b *testing.B) { benchmarkParse(b, ".yaml", "auld-lang-syne.yaml") } + +// Content-sniff path (empty suffix) — what embedded tags and plugins hit. +func BenchmarkParseLyrics_SniffTTML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_SniffSRT(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_SniffYAML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.yaml") } +func BenchmarkParseLyrics_SniffLRC(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_SniffPlain(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.txt") } diff --git a/model/lyrics_lrc.go b/model/lyrics_lrc.go new file mode 100644 index 000000000..2cccb9d51 --- /dev/null +++ b/model/lyrics_lrc.go @@ -0,0 +1,358 @@ +package model + +import ( + "cmp" + "regexp" + "slices" + "strconv" + "strings" + "unicode" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/str" +) + +// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] +const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]` + +var ( + // Should either be at the beginning of file, or beginning of line + syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) + timeRegex = regexp.MustCompile(timeRegexString) + lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + + // Enhanced LRC: inline word-level timing markers like <00:12.34> + enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` + enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) +) + +func parseLRC(language, text string) (*Lyrics, error) { + text = str.SanitizeText(text) + + lines := strings.Split(text, "\n") + structuredLines := make([]Line, 0, len(lines)*2) + + artist := "" + title := "" + var offset *int64 = nil + + synced := syncRegex.MatchString(text) + priorLine := "" + validLine := false + repeated := false + var timestamps []int64 + + for _, line := range lines { + line := strings.TrimSpace(line) + if line == "" { + if validLine { + priorLine += "\n" + } + continue + } + var text string + var time *int64 = nil + + if synced { + idTag := lrcIdRegex.FindStringSubmatch(line) + if idTag != nil { + switch idTag[1] { + case "ar": + artist = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "lang": + language = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "offset": + { + off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) + if err != nil { + log.Warn("Error parsing offset", "offset", idTag[2], "error", err) + } else { + offset = &off + } + } + case "ti": + title = str.SanitizeText(strings.TrimSpace(idTag[2])) + } + + continue + } + + times := timeRegex.FindAllStringSubmatchIndex(line, -1) + if len(times) > 1 { + repeated = true + } + + // The second condition is for when there is a timestamp in the middle of + // a line (after any text) + if len(times) == 0 || times[0][0] != 0 { + if validLine { + priorLine += "\n" + line + } + continue + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + timestamps = nil + } + + end := 0 + + // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] + for _, match := range times { + // for multiple matches, we need to check that later matches are not + // in the middle of the string + if end != 0 { + middle := strings.TrimSpace(line[end:match[0]]) + if middle != "" { + break + } + } + + end = match[1] + timeInMillis, err := parseTime(line, match) + if err != nil { + return nil, err + } + + timestamps = append(timestamps, timeInMillis) + } + + if end >= len(line) { + priorLine = "" + } else { + priorLine = strings.TrimSpace(line[end:]) + } + + validLine = true + } else { + text = line + structuredLines = append(structuredLines, Line{ + Start: time, + Value: text, + }) + } + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + } + + // If there are repeated values, there is no guarantee that they are in order + // In this, case, sort the lyrics by start time + if repeated { + slices.SortFunc(structuredLines, func(a, b Line) int { + return cmp.Compare(*a.Start, *b.Start) + }) + } + + lyrics := Lyrics{ + DisplayArtist: artist, + DisplayTitle: title, + Lang: language, + Line: normalizeCueLines(structuredLines), + Offset: offset, + Synced: synced, + } + return &lyrics, nil +} + +// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers +// and computes UTF-8 byte offsets against the final stripped line value. +func parseEnhancedLine(text string) (string, []Cue) { + matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return strings.TrimSpace(text), nil + } + + type segment struct { + start int64 + rawStart int + rawEnd int + } + + segments := make([]segment, 0, len(matches)) + var rawValue strings.Builder + var trailingEnd *int64 + for i, match := range matches { + timeMs, err := parseTime( + // Rewrite <...> as [...] so parseTime can handle it with the same logic + "["+text[match[0]+1:match[1]-1]+"]", + // Adjust match indices to point into our rewritten string (need start/end pairs for each group) + []int{ + 0, match[1] - match[0], + adjustGroup(match, 2), adjustGroup(match, 3), + adjustGroup(match, 4), adjustGroup(match, 5), + adjustGroup(match, 6), adjustGroup(match, 7), + adjustGroup(match, 8), adjustGroup(match, 9), + }, + ) + if err != nil { + continue + } + + // Text runs from after this marker to the start of the next marker (or end of string) + textStart := match[1] + var textEnd int + if i+1 < len(matches) { + textEnd = matches[i+1][0] + } else { + textEnd = len(text) + } + + word := text[textStart:textEnd] + if word == "" { + if i == len(matches)-1 { + trailingEnd = &timeMs + } + continue + } + + rawStart := rawValue.Len() + rawValue.WriteString(word) + segments = append(segments, segment{ + start: timeMs, + rawStart: rawStart, + rawEnd: rawValue.Len(), + }) + } + + if len(segments) == 0 { + return strings.TrimSpace(stripEnhancedMarkers(text)), nil + } + + finalRaw := rawValue.String() + leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace)) + rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace)) + trimmedEnd := len(finalRaw) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + cues := make([]Cue, 0, len(segments)) + for _, seg := range segments { + start := seg.start + byteStart := max(seg.rawStart, leftTrimBytes) + byteEnd := min(seg.rawEnd, trimmedEnd) + if byteStart >= byteEnd { + continue + } + + cues = append(cues, Cue{ + Start: &start, + Value: finalRaw[byteStart:byteEnd], + ByteStart: byteStart - leftTrimBytes, + ByteEnd: byteEnd - leftTrimBytes - 1, + }) + } + + if trailingEnd != nil && len(cues) > 0 { + cues[len(cues)-1].End = trailingEnd + } + + return strings.TrimSpace(finalRaw), cues +} + +// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. +// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. +func adjustGroup(match []int, groupIdx int) int { + orig := match[groupIdx] + if orig == -1 { + return -1 + } + // Offset is: original position minus the position of '<' in the original, plus 1 for '[' + return orig - match[0] +} + +// stripEnhancedMarkers removes all inline markers from text, +// returning the plain lyric text. +func stripEnhancedMarkers(text string) string { + return enhancedLRCRegex.ReplaceAllString(text, "") +} + +// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End +// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute +// timestamps anchored at the line's first occurrence, so repeated-line LRC +// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the +// second occurrence to point at the correct moment. Returned *int64 pointers +// are freshly allocated so the input slice is never aliased into the result. +func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { + if len(baseCues) == 0 { + return nil + } + out := make([]Cue, len(baseCues)) + for i, c := range baseCues { + out[i] = c + if c.Start != nil { + s := *c.Start + offsetMs + out[i].Start = &s + } + if c.End != nil { + e := *c.End + offsetMs + out[i].End = &e + } + } + return out +} + +func parseTime(line string, match []int) (int64, error) { + var hours, millis int64 + var err error + + hourStart := match[2] + if hourStart != -1 { + // subtract 1 because group has : at the end + hourEnd := match[3] - 1 + hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) + if err != nil { + return 0, err + } + } + + minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) + if err != nil { + return 0, err + } + + sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) + if err != nil { + return 0, err + } + + msStart := match[8] + if msStart != -1 { + msEnd := match[9] + // +1 offset since this capture group contains . + millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) + if err != nil { + return 0, err + } + + length := msEnd - msStart + + if length == 3 { + millis *= 10 + } else if length == 2 { + millis *= 100 + } + } + + timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis + return timeInMillis, nil +} diff --git a/model/lyrics_lrc_test.go b/model/lyrics_lrc_test.go new file mode 100644 index 000000000..87514caf1 --- /dev/null +++ b/model/lyrics_lrc_test.go @@ -0,0 +1,255 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseLRC", func() { + It("should parse tags with spaces", func() { + lyrics, err := parseLRC("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Lang).To(Equal("eng")) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.DisplayArtist).To(Equal("An artist")) + Expect(lyrics.DisplayTitle).To(Equal("A title")) + Expect(lyrics.Offset).To(Equal(new(int64(1551)))) + }) + + It("Should ignore bad offset", func() { + lyrics, err := parseLRC("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Offset).To(BeNil()) + }) + + It("should accept lines with no text and weird times", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Hi there"}, + {Start: new(int64(10040)), Value: ""}, + {Start: new(int64(40000)), Value: "Test"}, + {Start: new(int64(1000 * 60 * 60)), Value: "late"}, + })) + }) + + It("Should support multiple timestamps per line", func() { + lyrics, err := parseLRC("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: ""}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, + })) + }) + + It("Should support parsing multiline string", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, + {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, + })) + }) + + It("Does not match timestamp in middle of line", func() { + lyrics, err := parseLRC("xxx", "This could [00:00:00] be a synced file") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeFalse()) + Expect(lyrics.Line).To(Equal([]Line{ + {Value: "This could [00:00:00] be a synced file"}, + })) + }) + + It("Allows timestamp in middle of line if also at beginning", func() { + lyrics, err := parseLRC("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, + {Start: new(int64(1000)), Value: "Line 2"}, + })) + }) + + It("Ignores lines in synchronized lyric prior to first timestamp", func() { + lyrics, err := parseLRC("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Text"}, + })) + }) + + It("Handles all possible ms cases", func() { + lyrics, err := parseLRC("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(1)), Value: "a"}, + {Start: new(int64(10)), Value: "b"}, + {Start: new(int64(100)), Value: "c"}, + })) + }) + + It("Properly sorts repeated lyrics out of order", func() { + lyrics, err := parseLRC("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Test"}, + {Start: new(int64(40000)), Value: "Not repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, + })) + }) + + It("should parse Enhanced LRC with word-level timing", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) + + line0 := lyrics.Line[0] + Expect(line0.Start).To(Equal(&t1000)) + Expect(line0.End).To(Equal(&t3000)) + Expect(line0.Value).To(Equal("Some lyrics here")) + Expect(line0.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, + {Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, + })) + + line1 := lyrics.Line[1] + Expect(line1.Start).To(Equal(&t3000)) + Expect(line1.End).To(Equal(&t3500)) + Expect(line1.Value).To(Equal("More words")) + Expect(line1.Cue).To(Equal([]Cue{ + {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + + Expect(line1.Cue[1].End).To(BeNil()) + }) + + It("should not parse malformed Enhanced LRC timing markers", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01a50>Not a marker") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(1000)), Value: "<00:01a50>Not a marker"}, + })) + }) + + It("should handle mixed Enhanced and plain LRC lines", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(3)) + + t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) + t3000 := int64(3000) + + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + Expect(lyrics.Line[0].End).To(Equal(&t3000)) + + Expect(lyrics.Line[1].Cue).To(BeNil()) + Expect(lyrics.Line[1].Value).To(Equal("Plain line")) + + Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ + {Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + Expect(lyrics.Line[2].Value).To(Equal("More words")) + }) + + It("should preserve byte offsets for Enhanced LRC cues", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(1)) + + t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600) + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Oh love me tonight")) + Expect(line.Cue).To(Equal([]Cue{ + {Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2}, + {Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6}, + {Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10}, + {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, + })) + }) + + It("should use a trailing Enhanced LRC marker as the end of the last word", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics<00:02.00>\n[00:30.00]Instrumental over") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t2000 := int64(1000), int64(1500), int64(2000) + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Some lyrics")) + Expect(line.End).To(Equal(&t2000)) + Expect(line.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t2000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + }) + + It("should shift a trailing Enhanced LRC marker for repeated line occurrences", func() { + lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world<00:10.90>") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t10100, t10500, t10900 := int64(10100), int64(10500), int64(10900) + t30100, t30500, t30900 := int64(30100), int64(30500), int64(30900) + + Expect(lyrics.Line[0].End).To(Equal(&t10900)) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t10900, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].End).To(Equal(&t30900)) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, End: &t30500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, End: &t30900, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) + + It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { + lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t10000 := int64(10000) + t10100 := int64(10100) + t10500 := int64(10500) + t30000 := int64(30000) + t30100 := int64(30100) + t30500 := int64(30500) + + Expect(lyrics.Line[0].Start).To(Equal(&t10000)) + Expect(lyrics.Line[0].End).To(Equal(&t30000)) + Expect(lyrics.Line[0].Value).To(Equal("Hello world")) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].Start).To(Equal(&t30000)) + Expect(lyrics.Line[1].End).To(Equal(&t30500)) + Expect(lyrics.Line[1].Value).To(Equal("Hello world")) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) +}) diff --git a/model/lyrics_lyricsfile.go b/model/lyrics_lyricsfile.go new file mode 100644 index 000000000..49d416f3f --- /dev/null +++ b/model/lyrics_lyricsfile.go @@ -0,0 +1,283 @@ +package model + +import ( + "bytes" + "fmt" + "strings" + + "github.com/navidrome/navidrome/utils/str" + "gopkg.in/yaml.v3" +) + +// parseLyricsfile parses a LRCLIB Lyricsfile YAML document +// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) +// into a model.LyricList containing a single main Lyrics entry. Returns +// (nil, nil) when the input parses as YAML but does not declare Lyricsfile +// version 1.0. +// +// When the source contains per-word timing via lines[].words[], each word +// becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and +// overlapping lines are attributed to synthetic voice agents via lowest-free +// voice ID assignment so the OpenSubsonic v2 enhanced response can split +// parallel vocals. +func parseLyricsfile(lang string, contents []byte) (LyricList, error) { + var doc lyricsfileDocument + dec := yaml.NewDecoder(bytes.NewReader(contents)) + dec.KnownFields(false) + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) + } + + if strings.TrimSpace(doc.Version) != lyricsfileVersion { + return nil, nil + } + + // Fall back to the caller's language when the document omits its own, matching + // the SRT/TTML parsers; normalizeLyricLang yields "xxx" only if both are empty. + docLang := doc.Metadata.Language + if strings.TrimSpace(docLang) == "" { + docLang = lang + } + lyrics := Lyrics{ + DisplayArtist: str.SanitizeText(doc.Metadata.Artist), + DisplayTitle: str.SanitizeText(doc.Metadata.Title), + Lang: normalizeLyricLang(docLang), + Kind: LyricKindMain, + } + if doc.Metadata.OffsetMs != 0 { + off := doc.Metadata.OffsetMs + lyrics.Offset = &off + } + + if doc.Metadata.Instrumental { + return LyricList{normalizeLyrics(lyrics)}, nil + } + + if len(doc.Lines) == 0 { + lines := buildPlainLyricsfileLines(doc.Plain) + if len(lines) == 0 { + return nil, nil + } + lyrics.Line = lines + return LyricList{normalizeLyrics(lyrics)}, nil + } + + lines, agents := buildLyricsfileLines(doc.Lines) + lyrics.Line = lines + lyrics.Agents = agents + lyrics.Synced = true + return LyricList{normalizeLyrics(lyrics)}, nil +} + +const lyricsfileVersion = "1.0" + +type lyricsfileDocument struct { + Version string `yaml:"version"` + Metadata lyricsfileMetadata `yaml:"metadata"` + Lines []lyricsfileLineEntry `yaml:"lines"` + Plain string `yaml:"plain"` +} + +type lyricsfileMetadata struct { + Title string `yaml:"title"` + Artist string `yaml:"artist"` + Album string `yaml:"album"` + DurationMs int64 `yaml:"duration_ms"` + OffsetMs int64 `yaml:"offset_ms"` + Language string `yaml:"language"` + Instrumental bool `yaml:"instrumental"` +} + +type lyricsfileLineEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` + Words []lyricsfileWordEntry `yaml:"words"` +} + +type lyricsfileWordEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` +} + +// buildLyricsfileLines converts YAML line entries to model.Line entries with +// per-cue AgentIDs assigned by streaming overlap clustering (lowest-free +// voice ID). The Agents slice is emitted only when at least one cue carries +// attribution AND more than one voice is used; otherwise AgentIDs are +// stripped so the wire shape stays simple per the OpenSubsonic spec rule +// "agents should not be emitted without cueLine data". +func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) { + if len(entries) == 0 { + return nil, nil + } + + // Resolved end timestamps per entry: explicit end_ms, final word end_ms, + // then the next entry's start. The last entry's end stays nil when no + // explicit or word-level end is available. + ends := make([]*int64, len(entries)) + for i := range entries { + var nextStart *int64 + if i+1 < len(entries) { + v := entries[i+1].StartMs + nextStart = &v + } + ends[i] = lyricsfileLineEnd(entries[i], nextStart) + } + + active := map[int]int64{} + maxVoice := -1 + anyCues := false + lines := make([]Line, 0, len(entries)) + + for i, entry := range entries { + for vID, vEnd := range active { + if vEnd <= entry.StartMs { + delete(active, vID) + } + } + + voiceID := 0 + for { + if _, busy := active[voiceID]; !busy { + break + } + voiceID++ + } + if voiceID > maxVoice { + maxVoice = voiceID + } + + agentID := fmt.Sprintf("voice-%d", voiceID) + cues, value := wordsToLineCues(entry, agentID) + if len(cues) > 0 { + anyCues = true + } + + startMs := entry.StartMs + line := Line{ + Start: &startMs, + End: ends[i], + Value: value, + Cue: cues, + } + lines = append(lines, line) + + var endMs int64 + if ends[i] != nil { + endMs = *ends[i] + } else { + endMs = entry.StartMs + } + active[voiceID] = endMs + } + + // Monophonic source, or attribution that has nowhere to land: emit no + // agents and strip per-cue AgentIDs to keep the wire shape simple. + if maxVoice <= 0 || !anyCues { + for i := range lines { + for j := range lines[i].Cue { + lines[i].Cue[j].AgentID = "" + } + } + return lines, nil + } + + agents := make([]Agent, 0, maxVoice+1) + for v := 0; v <= maxVoice; v++ { + role := "voice" + if v == 0 { + role = "main" + } + agents = append(agents, Agent{ + ID: fmt.Sprintf("voice-%d", v), + Role: role, + }) + } + return lines, agents +} + +func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 { + if entry.EndMs != nil { + v := *entry.EndMs + return &v + } + if len(entry.Words) > 0 { + lastWord := entry.Words[len(entry.Words)-1] + if lastWord.EndMs != nil { + v := *lastWord.EndMs + return &v + } + } + if nextStart != nil { + v := *nextStart + return &v + } + return nil +} + +func buildPlainLyricsfileLines(plain string) []Line { + plain = str.SanitizeText(plain) + rawLines := strings.Split(plain, "\n") + lines := make([]Line, 0, len(rawLines)) + for _, raw := range rawLines { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + lines = append(lines, Line{Value: value}) + } + return lines +} + +// wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue +// entries with inclusive UTF-8 byte offsets into the reconstructed line +// value. The line value is built from cue text concatenation rather than +// trusting entry.Text, because the Lyricsfile spec only requires word.text +// to "approximate" line.text - byte offsets must always land inside +// Line.Value. +func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string) { + if len(entry.Words) == 0 { + return nil, str.SanitizeText(entry.Text) + } + + var sb strings.Builder + for _, w := range entry.Words { + sb.WriteString(w.Text) + } + lineValue := sb.String() + + cues := make([]Cue, len(entry.Words)) + cursor := 0 + for i, w := range entry.Words { + valueBytes := len(w.Text) + bs := cursor + be := bs + if valueBytes > 0 { + be = bs + valueBytes - 1 + cursor = be + 1 + } + + s := w.StartMs + cue := Cue{ + Start: &s, + Value: w.Text, + ByteStart: bs, + ByteEnd: be, + AgentID: agentID, + } + if w.EndMs != nil { + e := *w.EndMs + cue.End = &e + } + cues[i] = cue + } + + for i := 0; i < len(cues)-1; i++ { + if cues[i].End == nil && cues[i+1].Start != nil { + v := *cues[i+1].Start + cues[i].End = &v + } + } + return cues, lineValue +} diff --git a/model/lyrics_lyricsfile_test.go b/model/lyrics_lyricsfile_test.go new file mode 100644 index 000000000..45899cda7 --- /dev/null +++ b/model/lyrics_lyricsfile_test.go @@ -0,0 +1,300 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseLyricsfile", func() { + DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker", + func(input string) { + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(BeNil()) + }, + Entry("arbitrary YAML", "hello: world\n"), + Entry("Lyricsfile-shaped but unversioned", `metadata: + title: 'Looks close' +lines: + - text: "But should not be claimed" + start_ms: 1000 +`), + ) + + It("returns an error for invalid YAML", func() { + _, err := parseLyricsfile("", []byte("not: valid: yaml: [")) + Expect(err).To(HaveOccurred()) + }) + + It("parses line-level metadata without cues", func() { + input := `version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Test Artist")) + Expect(l.DisplayTitle).To(Equal("Sample Track")) + Expect(l.Synced).To(BeTrue()) + Expect(l.Offset).ToNot(BeNil()) + Expect(*l.Offset).To(Equal(int64(-100))) + Expect(l.Agents).To(BeNil()) + + Expect(l.Line).To(HaveLen(2)) + Expect(*l.Line[0].Start).To(Equal(int64(18800))) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(22801))) + Expect(l.Line[0].Value).To(Equal("We're no strangers to love")) + Expect(l.Line[0].Cue).To(BeNil()) + + Expect(*l.Line[1].Start).To(Equal(int64(22801))) + Expect(l.Line[1].End).To(BeNil()) + Expect(l.Line[1].Value).To(Equal("You know the rules and so do I")) + Expect(l.Line[1].Cue).To(BeNil()) + }) + + DescribeTable("resolves the lyric language", + func(metaLanguage, callerLang, want string) { + input := "version: '1.0'\nmetadata:\n title: 'T'\n" + if metaLanguage != "" { + input += " language: '" + metaLanguage + "'\n" + } + input += "lines:\n - text: \"line\"\n start_ms: 0\n" + + lyrics, err := parseLyricsfile(callerLang, []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Lang).To(Equal(want)) + }, + Entry("prefers the document's own language", "eng", "deu", "eng"), + Entry("falls back to the caller language when metadata omits it", "", "deu", "deu"), + Entry("uses xxx when neither is provided", "", "", "xxx"), + ) + + It("parses plain-only Lyricsfile lyrics as unsynced lines", func() { + input := `version: '1.0' +metadata: + title: 'Plain Track' + artist: 'Plain Artist' + language: 'en' +lines: [] +plain: | + [Verse 1] + First line + + Second line +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("en")) + Expect(l.DisplayArtist).To(Equal("Plain Artist")) + Expect(l.DisplayTitle).To(Equal("Plain Track")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(Equal([]Line{ + {Value: "[Verse 1]"}, + {Value: "First line"}, + {Value: "Second line"}, + })) + }) + + It("produces word cues with inclusive UTF-8 byte offsets for monophonic word data", func() { + input := `version: '1.0' +metadata: + title: 'Karaoke' + artist: 'Singer' + language: 'eng' +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Synced).To(BeTrue()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(HaveLen(1)) + + line := l.Line[0] + Expect(*line.Start).To(Equal(int64(1000))) + Expect(*line.End).To(Equal(int64(3000))) + Expect(line.Value).To(Equal("Hello world")) + Expect(line.Cue).To(HaveLen(2)) + + Expect(*line.Cue[0].Start).To(Equal(int64(1000))) + Expect(*line.Cue[0].End).To(Equal(int64(1500))) + Expect(line.Cue[0].Value).To(Equal("Hello ")) + Expect(line.Cue[0].ByteStart).To(Equal(0)) + Expect(line.Cue[0].ByteEnd).To(Equal(5)) + Expect(line.Cue[0].AgentID).To(Equal("")) + + Expect(*line.Cue[1].Start).To(Equal(int64(1500))) + Expect(*line.Cue[1].End).To(Equal(int64(3000))) + Expect(line.Cue[1].Value).To(Equal("world")) + Expect(line.Cue[1].ByteStart).To(Equal(6)) + Expect(line.Cue[1].ByteEnd).To(Equal(10)) + Expect(line.Cue[1].AgentID).To(Equal("")) + }) + + It("prefers final word end_ms over next line start when inferring line end", func() { + input := `version: '1.0' +metadata: + title: 'Overlap From Words' +lines: + - text: "Long vocal" + start_ms: 1000 + words: + - text: "Long " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 3000 + end_ms: 3500 + words: + - text: "echo" + start_ms: 3000 + end_ms: 3500 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue[1].End).To(Equal(l.Line[0].End)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + }) + + It("synthesises voice agents for overlapping lines and attributes per-cue", func() { + input := `version: '1.0' +metadata: + title: 'Duet' +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + + Expect(l.Line[0].Value).To(Equal("Lead vocal")) + Expect(*l.Line[0].Start).To(Equal(int64(1000))) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue).To(HaveLen(2)) + Expect(l.Line[0].Cue[0].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[1].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[0].Cue[0].ByteEnd).To(Equal(4)) + Expect(l.Line[0].Cue[1].ByteStart).To(Equal(5)) + Expect(l.Line[0].Cue[1].ByteEnd).To(Equal(9)) + + Expect(l.Line[1].Value).To(Equal("echo")) + Expect(*l.Line[1].Start).To(Equal(int64(2000))) + Expect(*l.Line[1].End).To(Equal(int64(3000))) + Expect(l.Line[1].Cue).To(HaveLen(1)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + Expect(l.Line[1].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[1].Cue[0].ByteEnd).To(Equal(3)) + }) + + It("emits empty lines with Synced=false for instrumental tracks", func() { + input := `version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Composer")) + Expect(l.DisplayTitle).To(Equal("Solo Piano")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Line).To(BeEmpty()) + Expect(l.Agents).To(BeNil()) + }) + + It("strips agent attribution when overlapping lines carry no cues", func() { + input := `version: '1.0' +lines: + - text: "Lead" + start_ms: 1000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Line).To(HaveLen(2)) + Expect(l.Agents).To(BeNil()) + Expect(l.Line[0].Cue).To(BeNil()) + Expect(l.Line[1].Cue).To(BeNil()) + }) +}) diff --git a/model/lyrics_normalize.go b/model/lyrics_normalize.go new file mode 100644 index 000000000..a7d4b4e2a --- /dev/null +++ b/model/lyrics_normalize.go @@ -0,0 +1,171 @@ +package model + +import ( + "slices" + + "github.com/navidrome/navidrome/utils/gg" +) + +func normalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line = normalizeCueLines(lyrics.Line) + if len(lyrics.Agents) == 0 { + lyrics.Agents = nil + } + return lyrics +} + +func normalizeCueLines(lines []Line) []Line { + if len(lines) == 0 { + return lines + } + + normalized := make([]Line, len(lines)) + copy(normalized, lines) + + for i := range normalized { + if len(normalized[i].Cue) > 0 { + normalized[i].Cue = slices.Clone(normalized[i].Cue) + } + + var fallbackEnd *int64 + if normalized[i].End != nil { + v := *normalized[i].End + fallbackEnd = &v + } else if i+1 < len(normalized) && normalized[i+1].Start != nil { + v := *normalized[i+1].Start + fallbackEnd = &v + } + + normalized[i] = normalizeCueLine(normalized[i], fallbackEnd) + } + + return normalized +} + +func normalizeLineTiming(line Line) Line { + if len(line.Cue) == 0 { + return line + } + + var earliestStart *int64 + var latestEnd *int64 + for i := range line.Cue { + token := line.Cue[i] + if token.Start != nil { + if earliestStart == nil || *token.Start < *earliestStart { + v := *token.Start + earliestStart = &v + } + } + + candidateEnd := token.End + if candidateEnd == nil { + candidateEnd = token.Start + } + if candidateEnd != nil { + if latestEnd == nil || *candidateEnd > *latestEnd { + v := *candidateEnd + latestEnd = &v + } + } + } + + if line.Start == nil && earliestStart != nil { + v := *earliestStart + line.Start = &v + } + if line.End == nil && latestEnd != nil { + v := *latestEnd + line.End = &v + } + return line +} + +func normalizeCueLine(line Line, fallbackEnd *int64) Line { + if len(line.Cue) == 0 { + return line + } + line.Cue = normalizeCueEndsByAgent(line.Cue, fallbackEnd) + return normalizeLineTiming(line) +} + +// normalizeCueEndsByAgent resolves cue end times independently per agent so that +// background (or other parallel) layers, whose cues interleave with the main +// timeline but are stored together in document order, do not clamp each other's +// ends. Each agent group is normalized in its own document order; results are +// reassembled into the original cue positions. +func normalizeCueEndsByAgent(cues []Cue, fallbackEnd *int64) []Cue { + groups := make(map[string][]int) + order := make([]string, 0, 2) + for i := range cues { + id := cues[i].AgentID + if _, ok := groups[id]; !ok { + order = append(order, id) + } + groups[id] = append(groups[id], i) + } + + // Single agent: the document order already matches the timeline, so the + // straightforward normalization applies without regrouping. + if len(order) <= 1 { + return NormalizeCueEnds(cues, fallbackEnd) + } + + out := slices.Clone(cues) + for _, id := range order { + idxs := groups[id] + group := make([]Cue, len(idxs)) + for gi, pos := range idxs { + group[gi] = cues[pos] + } + group = NormalizeCueEnds(group, fallbackEnd) + for gi, pos := range idxs { + out[pos] = group[gi] + } + } + return out +} + +// NormalizeCueEnds resolves missing cue end times within a single ordered cue +// group: each end is filled from the next cue's start, then from fallbackEnd, +// and is clamped so it never precedes the cue's own start nor overruns the next +// cue. End times are all-or-none — if any cue still lacks an end afterwards, all +// ends in the group are cleared. The input slice is never mutated. +// +// Exported because the Subsonic enhanced-lyrics serializer resolves cue ends +// per agent group while building the response; all other normalization is +// package-internal. +func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { + if len(cues) == 0 { + return cues + } + + out := slices.Clone(cues) + for i := range out { + end := out[i].End + if end == nil { + if i+1 < len(out) && out[i+1].Start != nil { + end = out[i+1].Start + } else { + end = fallbackEnd + } + } + if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { + end = out[i+1].Start + } + if end != nil && out[i].Start != nil && *end < *out[i].Start { + end = out[i].Start + } + out[i].End = gg.Clone(end) + } + + for i := range out { + if out[i].End == nil { + for j := range out { + out[j].End = nil + } + break + } + } + return out +} diff --git a/model/lyrics_normalize_test.go b/model/lyrics_normalize_test.go new file mode 100644 index 000000000..24faffd54 --- /dev/null +++ b/model/lyrics_normalize_test.go @@ -0,0 +1,120 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("normalizeCueLines", func() { + It("should not mutate caller cue slices when filling missing cue end times", func() { + start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000) + lines := []Line{ + { + Start: &start0, + Value: "Some lyrics", + Cue: []Cue{ + {Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + }, + }, + { + Start: &nextLineStart, + Value: "Next line", + }, + } + + normalized := normalizeCueLines(lines) + + Expect(normalized[0].Cue[0].End).To(Equal(&start1)) + Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) + Expect(lines[0].Cue[0].End).To(BeNil()) + Expect(lines[0].Cue[1].End).To(BeNil()) + }) +}) + +var _ = Describe("NormalizeCueEnds", func() { + // p returns a fresh pointer so cases don't share *int64 state. + p := func(v int64) *int64 { return &v } + + // endsOf extracts the resolved end times (nil-safe) for compact assertions. + endsOf := func(cues []Cue) []*int64 { + out := make([]*int64, len(cues)) + for i := range cues { + out[i] = cues[i].End + } + return out + } + + It("returns the input as-is when empty", func() { + Expect(NormalizeCueEnds(nil, p(1000))).To(BeNil()) + Expect(NormalizeCueEnds([]Cue{}, p(1000))).To(BeEmpty()) + }) + + It("fills a missing end from the next cue's start", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(3000)})) + }) + + It("fills the last cue's missing end from fallbackEnd", func() { + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1200), p(3000)})) + }) + + It("clamps an end that overruns the next cue's start", func() { + cues := []Cue{ + {Start: p(1000), End: p(9999)}, + {Start: p(1500), End: p(2000)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(2000)})) + }) + + It("clamps an end that precedes the cue's own start", func() { + cues := []Cue{ + {Start: p(1000), End: p(500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1000)})) + }) + + It("clears all ends when any cue still lacks one (all-or-none)", func() { + // The last cue has no end and there is no fallback, so it stays nil and + // every end in the group is cleared. + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, nil) + + Expect(endsOf(out)).To(Equal([]*int64{nil, nil})) + }) + + It("does not mutate the input slice", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + _ = NormalizeCueEnds(cues, p(3000)) + + Expect(cues[0].End).To(BeNil()) + Expect(cues[1].End).To(BeNil()) + }) +}) diff --git a/model/lyrics_parse.go b/model/lyrics_parse.go new file mode 100644 index 000000000..4bfaa29e8 --- /dev/null +++ b/model/lyrics_parse.go @@ -0,0 +1,73 @@ +package model + +import ( + "bytes" + "fmt" + "slices" + "strings" + + "github.com/navidrome/navidrome/log" +) + +// lyricParser returns an empty list (not an error) when the input is not its +// format, so parsers can be tried in order. lang is the default for formats that +// do not carry their own. +type lyricParser func(lang string, contents []byte) (LyricList, error) + +// lyricFormats is the structured formats in content-sniff probe order; each +// row's suffixes drive sidecar dispatch. LRC/plain is the unlisted fallback floor. +var lyricFormats = []struct { + suffixes []string + parse lyricParser +}{ + {[]string{".ttml"}, parseTTML}, + {[]string{".srt"}, parseSRT}, + {[]string{".yaml", ".yml"}, parseLyricsfile}, +} + +// ParseLyrics is the single entry point for parsing lyrics. A known suffix routes +// to that format's parser; an empty or "auto" suffix content-sniffs. Either way, +// a structured parser that does not match falls back to the LRC/plain-text floor. +func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) { + contents = stripBOM(contents) + suffix = strings.ToLower(suffix) + sniff := suffix == "" || suffix == "auto" + + // Sniffing tries every format in order; a known suffix selects just its own. + // Unmatched suffixes leave no candidates, so parseFirstMatch falls to plain. + candidates := make([]lyricParser, 0, len(lyricFormats)) + for _, f := range lyricFormats { + if sniff || slices.Contains(f.suffixes, suffix) { + candidates = append(candidates, f.parse) + } + } + return parseFirstMatch(lang, contents, candidates...) +} + +func parseFirstMatch(lang string, contents []byte, candidates ...lyricParser) (LyricList, error) { + for _, parse := range candidates { + list, err := parse(lang, contents) + if err == nil && len(list) > 0 { + return list, nil + } + if err != nil { + log.Warn("Error parsing lyrics, falling back to plain text", "error", err) + } + } + return plainLRC(lang, contents) +} + +func plainLRC(lang string, contents []byte) (LyricList, error) { + lyric, err := parseLRC(lang, string(contents)) + if err != nil { + return nil, fmt.Errorf("parsing lyrics: %w", err) + } + if lyric == nil || lyric.IsEmpty() { + return nil, nil + } + return LyricList{*lyric}, nil +} + +func stripBOM(contents []byte) []byte { + return bytes.TrimPrefix(contents, []byte("\ufeff")) +} diff --git a/model/lyrics_parse_test.go b/model/lyrics_parse_test.go new file mode 100644 index 000000000..eb58a29ef --- /dev/null +++ b/model/lyrics_parse_test.go @@ -0,0 +1,213 @@ +package model + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseLyrics", func() { + DescribeTable("known suffix routes to the matching parser", + func(suffix, contents string, wantSynced bool, wantFirst string) { + list, err := ParseLyrics(suffix, "eng", []byte(contents)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(Equal(wantSynced)) + Expect(list[0].Line[0].Value).To(Equal(wantFirst)) + }, + Entry(".lrc", ".lrc", "[00:01.00]lrc line", true, "lrc line"), + Entry(".txt plain", ".txt", "plain line", false, "plain line"), + Entry(".srt", ".srt", "1\n00:00:01,000 --> 00:00:02,000\nsrt line\n", true, "srt line"), + Entry(".ttml", ".ttml", `

ttml line

`, true, "ttml line"), + Entry(".yaml", ".yaml", "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: yaml line\n start_ms: 1000\n", true, "yaml line"), + ) + + It("empty suffix content-sniffs (TTML)", func() { + ttml := `

auto ttml

` + list, err := ParseLyrics("", "eng", []byte(ttml)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("auto ttml")) + }) + + It("empty suffix content-sniffs (YAML)", func() { + yaml := "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: auto yaml\n start_ms: 1000\n" + list, err := ParseLyrics("auto", "eng", []byte(yaml)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("auto yaml")) + }) + + It("falls back to plain text when a known suffix fails to parse structurally", func() { + list, err := ParseLyrics(".srt", "eng", []byte("not actually an srt file")) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line[0].Value).To(Equal("not actually an srt file")) + }) +}) + +var _ = Describe("ParseLyrics content-sniffing", func() { + It("should parse embedded TTML with the tag language as the default", func() { + content := ` + + + + Lead Vocal + + + + +
+

+ Hello world +

+
+ +
` + + list, err := ParseLyrics("", "ENG", []byte(content)) + + // ParseLyrics's job is to detect TTML and apply the tag language as the + // default; the parser's cue/agent details are covered in lyrics_ttml_test.go. + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Kind).To(Equal("main")) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line[0].Value).To(Equal("Hello world")) + }) + + It("should preserve embedded TTML translation and pronunciation tracks", func() { + content := ` + + + + + + Hola + + + + + konni + + + + + + +
+

こんにちは

+
+ +
` + + list, err := ParseLyrics("", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(3)) + Expect(list[0].Kind).To(Equal("main")) + Expect(list[0].Lang).To(Equal("ja")) + Expect(list[0].Line[0].Value).To(Equal("こんにちは")) + Expect(list[1].Kind).To(Equal("translation")) + Expect(list[1].Lang).To(Equal("es")) + Expect(list[1].Line[0].Value).To(Equal("Hola")) + Expect(list[2].Kind).To(Equal("pronunciation")) + Expect(list[2].Lang).To(Equal("ja-latn")) + Expect(list[2].Line[0].Value).To(Equal("konni")) + Expect(list[2].Line[0].Cue).To(HaveLen(2)) + }) + + It("should parse embedded SRT with the tag language", func() { + content := `1 +00:00:18,800 --> 00:00:22,800 +We're from subtitles + +2 +00:00:22,801 --> 00:00:26,000 +Another subtitle line` + + list, err := ParseLyrics("", "POR", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(Equal(LyricList{ + { + Lang: "por", + Line: []Line{ + { + Start: new(int64(18800)), + End: new(int64(22800)), + Value: "We're from subtitles", + }, + { + Start: new(int64(22801)), + End: new(int64(26000)), + Value: "Another subtitle line", + }, + }, + Synced: true, + }, + })) + }) + + It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() { + content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle" + + list, err := ParseLyrics("", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(Equal([]Line{ + {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, + {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, + })) + }) + + It("should keep embedded enhanced LRC cues", func() { + content := "[00:01.00]<00:01.00>Lead <00:01.50>words" + + list, err := ParseLyrics("", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line[0].Value).To(Equal("Lead words")) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + }) + + It("should fall back to plain lyrics when embedded TTML is invalid", func() { + content := ` + +

Broken

+ +
` + + list, err := ParseLyrics("", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).ToNot(BeEmpty()) + values := make([]string, 0, len(list[0].Line)) + for _, line := range list[0].Line { + values = append(values, line.Value) + } + Expect(strings.Join(values, "\n")).To(ContainSubstring("Broken")) + }) + + It("detects a Lyricsfile YAML payload via content-sniffing", func() { + yaml := "version: \"1.0\"\nmetadata:\n title: Song\n language: eng\nlines:\n - text: sniffed yaml line\n start_ms: 1000\n" + + list, err := ParseLyrics("", "eng", []byte(yaml)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("sniffed yaml line")) + }) +}) diff --git a/model/lyrics_srt.go b/model/lyrics_srt.go new file mode 100644 index 000000000..319a59961 --- /dev/null +++ b/model/lyrics_srt.go @@ -0,0 +1,158 @@ +package model + +import ( + "regexp" + "strconv" + "strings" + + "github.com/navidrome/navidrome/utils/str" +) + +var ( + srtTimeRegex = regexp.MustCompile(`^\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*$`) + srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`) +) + +func parseSRT(language string, contents []byte) (LyricList, error) { + raw := strings.ReplaceAll(string(contents), "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + blocks := splitSRTBlocks(raw) + lines := make([]Line, 0, len(blocks)) + + for _, block := range blocks { + line, ok, err := parseSRTBlock(block) + if err != nil { + return nil, err + } + if ok { + lines = append(lines, line) + } + } + + if len(lines) == 0 { + return nil, nil + } + + lyrics := normalizeLyrics(Lyrics{ + Lang: normalizeLyricLang(language), + Line: lines, + Synced: true, + }) + return LyricList{lyrics}, nil +} + +func splitSRTBlocks(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + parts := srtBlockSeparatorRegex.Split(raw, -1) + blocks := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + blocks = append(blocks, part) + } + } + return blocks +} + +func parseSRTBlock(block string) (Line, bool, error) { + rawLines := strings.Split(block, "\n") + lines := make([]string, 0, len(rawLines)) + for _, line := range rawLines { + lines = append(lines, strings.TrimSpace(line)) + } + + if len(lines) == 0 { + return Line{}, false, nil + } + + startIdx := 0 + if digitsOnly(lines[0]) { + startIdx = 1 + } + if startIdx >= len(lines) { + return Line{}, false, nil + } + + timing := strings.Split(lines[startIdx], "-->") + if len(timing) != 2 { + return Line{}, false, nil + } + + startMs, err := parseSRTTime(timing[0]) + if err != nil { + return Line{}, false, err + } + endMs, err := parseSRTTime(timing[1]) + if err != nil { + return Line{}, false, err + } + + textLines := make([]string, 0, len(lines)-startIdx-1) + for _, line := range lines[startIdx+1:] { + if line == "" { + continue + } + textLines = append(textLines, line) + } + + value := str.SanitizeText(strings.Join(textLines, "\n")) + if value == "" { + return Line{}, false, nil + } + + return Line{ + Start: &startMs, + End: &endMs, + Value: value, + }, true, nil +} + +func parseSRTTime(value string) (int64, error) { + match := srtTimeRegex.FindStringSubmatch(strings.TrimSpace(value)) + if match == nil { + return 0, strconv.ErrSyntax + } + + hours, err := strconv.ParseInt(match[1], 10, 64) + if err != nil { + return 0, err + } + minutes, err := strconv.ParseInt(match[2], 10, 64) + if err != nil { + return 0, err + } + seconds, err := strconv.ParseInt(match[3], 10, 64) + if err != nil { + return 0, err + } + millis, err := strconv.ParseInt(match[4], 10, 64) + if err != nil { + return 0, err + } + + switch len(match[4]) { + case 1: + millis *= 100 + case 2: + millis *= 10 + } + + return (((hours*60)+minutes)*60+seconds)*1000 + millis, nil +} + +func digitsOnly(value string) bool { + if value == "" { + return false + } + for _, ch := range value { + if ch < '0' || ch > '9' { + return false + } + } + return true +} diff --git a/model/lyrics_srt_test.go b/model/lyrics_srt_test.go new file mode 100644 index 000000000..2c0ab2242 --- /dev/null +++ b/model/lyrics_srt_test.go @@ -0,0 +1,30 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseSRT", func() { + It("parses SRT blocks with the default language", func() { + content := []byte("1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n\n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle") + + list, err := parseSRT("xxx", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("xxx")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(Equal([]Line{ + {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, + {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, + })) + }) + + It("returns nil for input with no valid blocks", func() { + list, err := parseSRT("xxx", []byte("not actually an srt file")) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(BeNil()) + }) +}) diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 644b85ad2..fd954ad26 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -1,111 +1,69 @@ -package model_test +package model import ( - . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ToLyrics", func() { - It("should parse tags with spaces", func() { - lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Lang).To(Equal("eng")) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.DisplayArtist).To(Equal("An artist")) - Expect(lyrics.DisplayTitle).To(Equal("A title")) - Expect(lyrics.Offset).To(Equal(new(int64(1551)))) +var _ = Describe("Lyrics.EffectiveKind", func() { + It("defaults a blank kind to main", func() { + Expect(Lyrics{}.EffectiveKind()).To(Equal(LyricKindMain)) + Expect(Lyrics{Kind: " "}.EffectiveKind()).To(Equal(LyricKindMain)) }) - It("Should ignore bad offset", func() { - lyrics, err := ToLyrics("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Offset).To(BeNil()) - }) - - It("should accept lines with no text and weird times", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Hi there"}, - {Start: new(int64(10040)), Value: ""}, - {Start: new(int64(40000)), Value: "Test"}, - {Start: new(int64(1000 * 60 * 60)), Value: "late"}, - })) - }) - - It("Should support multiple timestamps per line", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: ""}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, - })) - }) - - It("Should support parsing multiline string", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, - {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, - })) - }) - - It("Does not match timestamp in middle of line", func() { - lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeFalse()) - Expect(lyrics.Line).To(Equal([]Line{ - {Value: "This could [00:00:00] be a synced file"}, - })) - }) - - It("Allows timestamp in middle of line if also at beginning", func() { - lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, - {Start: new(int64(1000)), Value: "Line 2"}, - })) - }) - - It("Ignores lines in synchronized lyric prior to first timestamp", func() { - lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Text"}, - })) - }) - - It("Handles all possible ms cases", func() { - lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(1)), Value: "a"}, - {Start: new(int64(10)), Value: "b"}, - {Start: new(int64(100)), Value: "c"}, - })) - }) - - It("Properly sorts repeated lyrics out of order", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Test"}, - {Start: new(int64(40000)), Value: "Not repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, - })) + It("returns the kind as-is when set", func() { + Expect(Lyrics{Kind: LyricKindTranslation}.EffectiveKind()).To(Equal(LyricKindTranslation)) + }) +}) + +var _ = Describe("Lyrics.IsMainKind", func() { + It("is true for a blank (untyped) kind", func() { + Expect(Lyrics{}.IsMainKind()).To(BeTrue()) + }) + + It("is true for the main kind", func() { + Expect(Lyrics{Kind: LyricKindMain}.IsMainKind()).To(BeTrue()) + }) + + It("is false for translation and pronunciation kinds", func() { + Expect(Lyrics{Kind: LyricKindTranslation}.IsMainKind()).To(BeFalse()) + Expect(Lyrics{Kind: LyricKindPronunciation}.IsMainKind()).To(BeFalse()) + }) +}) + +var _ = Describe("LyricList.Main", func() { + It("returns false when the list is empty", func() { + _, ok := LyricList{}.Main() + Expect(ok).To(BeFalse()) + }) + + It("returns the main-kind entry when present", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Kind: LyricKindMain, Lang: "xxx"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Kind).To(Equal(LyricKindMain)) + }) + + It("falls back to the first entry when no main kind exists", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Kind: LyricKindPronunciation, Lang: "ja"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Lang).To(Equal("en")) + }) + + It("treats a blank kind as main", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Lang: "xxx"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Lang).To(Equal("xxx")) }) }) diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go new file mode 100644 index 000000000..43d4699af --- /dev/null +++ b/model/lyrics_ttml.go @@ -0,0 +1,1296 @@ +package model + +import ( + "bytes" + "encoding/xml" + "errors" + "io" + "math" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/str" +) + +const ( + defaultTTMLFrameRate = 30.0 + defaultTTMLSubFrameRate = 1.0 + defaultTTMLTickRate = 1.0 + + ttmlBackgroundAgentPrefix = "__nd_bg__|" +) + +var offsetTimeRegex = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(h|m|s|ms|f|t)$`) +var xmlEncodingRegex = regexp.MustCompile(`(?i)<\?xml([^>]*?)encoding\s*=\s*["'][^"']+["']([^>]*)\?>`) + +type ttmlTimeKind int + +const ( + ttmlTimeAbsolute ttmlTimeKind = iota + ttmlTimeOffset + ttmlTimeAmbiguous +) + +type ttmlTimingParams struct { + frameRate float64 + subFrameRate float64 + tickRate float64 +} + +type ttmlTimingContext struct { + lang string + role string + agentID string + begin int64 + hasBegin bool + end int64 + hasEnd bool + invalid bool +} + +type ttmlLineRef struct { + order int + line Line +} + +type ttmlMetadataEntry struct { + key string + line Line + seq int +} + +type ttmlResolvedMetadataLine struct { + order int + seq int + line Line +} + +type ttmlDefinedAgent struct { + ID string + Type string + Name string +} + +type ttmlPiece struct { + raw string + cue *Cue + isBreak bool +} + +type ttmlParser struct { + decoder *xml.Decoder + params ttmlTimingParams + + mainLangOrder []string + mainLinesByLang map[string][]Line + + mainLineRefsByKey map[string]ttmlLineRef + mainLineOrder int + + translationLangOrder []string + translationEntriesByLg map[string][]ttmlMetadataEntry + + pronunciationLangOrder []string + pronunciationEntriesByLg map[string][]ttmlMetadataEntry + + definedAgents map[string]ttmlDefinedAgent + + metadataSeq int +} + +func isTTMLDocument(contents []byte) bool { + decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimSpace(contents))) + for { + token, err := decoder.Token() + if err != nil { + return false + } + if start, ok := token.(xml.StartElement); ok { + return strings.EqualFold(start.Name.Local, "tt") + } + } +} + +func parseTTML(defaultLang string, contents []byte) (LyricList, error) { + contents = xmlEncodingRegex.ReplaceAll(contents, []byte(``)) + + // Skip non-TTML content so sniffing doesn't run the full TTML parse on plain + // text — isTTMLDocument does a cheap decode that stops at the first element. + // Checked after the encoding fixup so UTF-16-declared documents are recognized. + if !isTTMLDocument(contents) { + return nil, nil + } + + p := ttmlParser{ + decoder: xml.NewDecoder(bytes.NewReader(contents)), + params: ttmlTimingParams{ + frameRate: defaultTTMLFrameRate, + subFrameRate: defaultTTMLSubFrameRate, + tickRate: defaultTTMLTickRate, + }, + mainLinesByLang: make(map[string][]Line), + mainLineRefsByKey: make(map[string]ttmlLineRef), + translationEntriesByLg: make(map[string][]ttmlMetadataEntry), + pronunciationEntriesByLg: make(map[string][]ttmlMetadataEntry), + definedAgents: make(map[string]ttmlDefinedAgent), + } + + root := ttmlTimingContext{lang: normalizeLyricLang(defaultLang)} + + for { + token, err := p.decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + + start, ok := token.(xml.StartElement) + if !ok { + continue + } + + if err := p.parseElement(start, root); err != nil { + return nil, err + } + } + + return p.toLyricList(), nil +} + +func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingContext) error { + local := strings.ToLower(start.Name.Local) + if local == "tt" { + p.updateTimingParams(start.Attr) + } + + switch local { + case "translation": + return p.parseMetadataTrack(start, parent, LyricKindTranslation) + case "transliteration": + return p.parseMetadataTrack(start, parent, LyricKindPronunciation) + case "agent": + return p.parseAgentDefinition(start) + } + + ctx := p.childContext(start.Attr, parent) + if local == "p" { + lineText, tokens, err := p.parseParagraph(ctx) + if err != nil { + return err + } + if ctx.invalid || lineText == "" { + return nil + } + + parsedLine := Line{Value: lineText} + if ctx.hasBegin { + startMs := ctx.begin + parsedLine.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + parsedLine.End = &endMs + } + if len(tokens) > 0 { + parsedLine.Cue = tokens + } + parsedLine = normalizeLineTiming(parsedLine) + + lineKey, _ := attrValue(start.Attr, "key") + p.addMainLine(ctx.lang, lineKey, parsedLine) + return nil + } + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + nextParent := ctx + if ctx.invalid { + // Best effort: ignore invalid timing in container elements, and + // continue traversing descendants with parent context. + nextParent = parent + } + if err := p.parseElement(t, nextParent); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return nil + } + } + } +} + +func (p *ttmlParser) parseMetadataTrack(start xml.StartElement, parent ttmlTimingContext, kind string) error { + ctx := p.childContext(start.Attr, parent) + lang := normalizeLyricLang(ctx.lang) + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + if strings.EqualFold(t.Name.Local, "text") { + entry, ok, err := p.parseMetadataText(t, ctx) + if err != nil { + return err + } + if ok { + p.addMetadataEntry(kind, lang, entry) + } + continue + } + + nextParent := ctx + if ctx.invalid { + nextParent = parent + } + if err := p.parseElement(t, nextParent); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return nil + } + } + } +} + +func (p *ttmlParser) parseAgentDefinition(start xml.StartElement) error { + id, ok := attrValue(start.Attr, "id") + id = strings.TrimSpace(id) + if !ok || id == "" { + return p.skipElement(start) + } + + agent := ttmlDefinedAgent{ + ID: id, + Type: strings.ToLower(strings.TrimSpace(attrOrEmpty(start.Attr, "type"))), + } + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + if strings.EqualFold(t.Name.Local, "name") { + name, err := p.collectElementText(t) + if err != nil { + return err + } + name = sanitizeTTMLText(name) + if name != "" && agent.Name == "" { + agent.Name = name + } + continue + } + if err := p.skipElement(t); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + p.definedAgents[agent.ID] = agent + return nil + } + } + } +} + +func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTimingContext) (ttmlMetadataEntry, bool, error) { + forKey, hasFor := attrValue(start.Attr, "for") + forKey = strings.TrimSpace(forKey) + + pieces, err := p.parseInlineElement(start, parent) + if err != nil { + return ttmlMetadataEntry{}, false, err + } + if !hasFor || forKey == "" { + return ttmlMetadataEntry{}, false, nil + } + + ctx := p.childContext(start.Attr, parent) + if ctx.invalid { + return ttmlMetadataEntry{}, false, nil + } + + value, tokens := buildTTMLLineFromPieces(pieces) + line := Line{Value: value} + if ctx.hasBegin { + startMs := ctx.begin + line.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + line.End = &endMs + } + if len(tokens) > 0 { + line.Cue = tokens + } + line = normalizeLineTiming(line) + + if line.Value == "" && len(line.Cue) == 0 { + return ttmlMetadataEntry{}, false, nil + } + + return ttmlMetadataEntry{key: forKey, line: line}, true, nil +} + +func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, error) { + var pieces []ttmlPiece + + for { + token, err := p.decoder.Token() + if err != nil { + return "", nil, err + } + + switch t := token.(type) { + case xml.StartElement: + inlinePieces, err := p.parseInlineElement(t, parent) + if err != nil { + return "", nil, err + } + pieces = append(pieces, inlinePieces...) + case xml.EndElement: + if strings.EqualFold(t.Name.Local, "p") { + value, tokens := buildTTMLLineFromPieces(pieces) + return value, tokens, nil + } + case xml.CharData: + pieces = append(pieces, ttmlPiece{raw: string(t)}) + } + } +} + +func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) { + local := strings.ToLower(start.Name.Local) + if local == "br" { + return []ttmlPiece{{isBreak: true}}, nil + } + + ctx := p.childContext(start.Attr, parent) + _, hasBegin := attrValue(start.Attr, "begin") + _, hasEnd := attrValue(start.Attr, "end") + _, hasDur := attrValue(start.Attr, "dur") + hasOwnTiming := hasBegin || hasEnd || hasDur + + var pieces []ttmlPiece + + for { + token, err := p.decoder.Token() + if err != nil { + return nil, err + } + + switch t := token.(type) { + case xml.StartElement: + inlinePieces, err := p.parseInlineElement(t, ctx) + if err != nil { + return nil, err + } + pieces = append(pieces, inlinePieces...) + case xml.EndElement: + if !strings.EqualFold(t.Name.Local, start.Name.Local) { + continue + } + + if local == "span" && hasOwnTiming && !ctx.invalid && !ttmlPiecesContainCue(pieces) { + rawValue := concatTTMLPieceRaw(pieces) + tokenText := sanitizeTTMLText(rawValue) + if tokenText != "" { + parsedToken := Cue{ + AgentID: p.resolveCueAgentID(ctx), + } + if ctx.hasBegin { + startMs := ctx.begin + parsedToken.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + parsedToken.End = &endMs + } + + return []ttmlPiece{{ + raw: rawValue, + cue: &parsedToken, + }}, nil + } + } + + return pieces, nil + case xml.CharData: + pieces = append(pieces, ttmlPiece{raw: string(t)}) + } + } +} + +func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) { + finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces)) + for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 { + finalized = finalized[1:] + } + for len(finalized) > 0 { + last := finalized[len(finalized)-1] + if last.text != "" || len(last.cues) > 0 { + break + } + finalized = finalized[:len(finalized)-1] + } + + var value strings.Builder + cues := make([]Cue, 0, 8) + byteOffset := 0 + for i, line := range finalized { + if i > 0 { + value.WriteByte('\n') + byteOffset++ + } + value.WriteString(line.text) + for _, cue := range line.cues { + cue.ByteStart += byteOffset + cue.ByteEnd += byteOffset + cues = append(cues, cue) + } + byteOffset += len(line.text) + } + + return value.String(), cues +} + +type ttmlFinalLine struct { + text string + cues []Cue +} + +func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine { + finalized := make([]ttmlFinalLine, 0, len(lines)) + for _, line := range lines { + text, cues := finalizeTTMLLogicalLine(line) + finalized = append(finalized, ttmlFinalLine{text: text, cues: cues}) + } + return finalized +} + +func splitTTMLPiecesByBreak(pieces []ttmlPiece) [][]ttmlPiece { + lines := [][]ttmlPiece{{}} + prevEndedWithSpace := true // leading whitespace on a fresh line is dropped + for _, piece := range pieces { + if piece.isBreak { + lines = append(lines, []ttmlPiece{}) + prevEndedWithSpace = true + continue + } + + raw := normalizeTTMLPieceRaw(piece.raw) + // Collapse whitespace across piece boundaries: a piece's leading space is + // redundant when the text emitted so far already ends with one. + if prevEndedWithSpace { + raw = strings.TrimPrefix(raw, " ") + } + if raw == "" { + continue + } + lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ + raw: raw, + cue: gg.Clone(piece.cue), + }) + prevEndedWithSpace = strings.HasSuffix(raw, " ") + } + return lines +} + +func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) { + rawLine := concatTTMLPieceRaw(line) + if rawLine == "" { + return "", nil + } + + leftTrimBytes := len(rawLine) - len(strings.TrimLeftFunc(rawLine, unicode.IsSpace)) + rightTrimBytes := len(rawLine) - len(strings.TrimRightFunc(rawLine, unicode.IsSpace)) + trimmedEnd := len(rawLine) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + trimmed := strings.TrimSpace(rawLine) + cues := make([]Cue, 0, len(line)) + cursor := 0 + for _, piece := range line { + pieceEnd := cursor + len(piece.raw) + if piece.cue != nil { + byteStart := max(cursor, leftTrimBytes) + byteEnd := min(pieceEnd, trimmedEnd) + if byteStart < byteEnd { + cue := *piece.cue + cue.Value = rawLine[byteStart:byteEnd] + cue.ByteStart = byteStart - leftTrimBytes + cue.ByteEnd = byteEnd - leftTrimBytes - 1 + cues = append(cues, cue) + } + } + cursor = pieceEnd + } + + return trimmed, cues +} + +// normalizeTTMLPieceRaw collapses whitespace following TTML's default mode +// (xml:space="default", the root default per TTML2 §8.1.1): per §8.2.10 that +// means linefeed-treatment="treat-as-space" and white-space-collapse="true", so +// linefeeds and other whitespace runs collapse to a single space. Collapsing is +// applied unconditionally; xml:space="preserve" is not supported (no lyric +// source in practice relies on it). Hard line breaks come only from
+// (§8.1.7), tracked separately via ttmlPiece.isBreak, so pretty-printed +// indentation between elements does not inject spurious newlines. +func normalizeTTMLPieceRaw(raw string) string { + raw = str.SanitizeText(raw) + return collapseTTMLWhitespace(raw) +} + +func collapseTTMLWhitespace(raw string) string { + var b strings.Builder + b.Grow(len(raw)) + prevSpace := false + for _, r := range raw { + // Only the XML S production (space, tab, CR, LF) is collapsible whitespace. + // Other Unicode spaces (e.g. NBSP, U+3000) are content characters, not + // whitespace, so they pass through unchanged. + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + if !prevSpace { + b.WriteByte(' ') + prevSpace = true + } + continue + } + b.WriteRune(r) + prevSpace = false + } + return b.String() +} + +func concatTTMLPieceRaw(pieces []ttmlPiece) string { + var raw strings.Builder + for _, piece := range pieces { + raw.WriteString(normalizeTTMLPieceRaw(piece.raw)) + } + return raw.String() +} + +func ttmlPiecesContainCue(pieces []ttmlPiece) bool { + for _, piece := range pieces { + if piece.cue != nil { + return true + } + } + return false +} + +func (p *ttmlParser) toLyricList() LyricList { + res := make(LyricList, 0, len(p.mainLangOrder)+len(p.translationLangOrder)+len(p.pronunciationLangOrder)) + for _, lang := range p.mainLangOrder { + lines := p.mainLinesByLang[lang] + if len(lines) == 0 { + continue + } + res = append(res, p.finalizeLyrics(Lyrics{ + Kind: LyricKindMain, + Lang: lang, + Line: lines, + Synced: linesAreSynced(lines), + })) + } + + res = append(res, p.buildMetadataLyrics(LyricKindTranslation, p.translationLangOrder, p.translationEntriesByLg)...) + res = append(res, p.buildMetadataLyrics(LyricKindPronunciation, p.pronunciationLangOrder, p.pronunciationEntriesByLg)...) + return res +} + +func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entriesByLang map[string][]ttmlMetadataEntry) LyricList { + res := make(LyricList, 0, len(langOrder)) + + for _, lang := range langOrder { + entries := entriesByLang[lang] + if len(entries) == 0 { + continue + } + + seenKeys := make(map[string]struct{}, len(entries)) + resolved := make([]ttmlResolvedMetadataLine, 0, len(entries)) + for _, entry := range entries { + if _, exists := seenKeys[entry.key]; exists { + continue + } + seenKeys[entry.key] = struct{}{} + + ref, ok := p.mainLineRefsByKey[entry.key] + if !ok { + log.Warn("Skipping TTML metadata line without matching key", "kind", kind, "lang", lang, "key", entry.key) + continue + } + + line := entry.line + if line.Start == nil && ref.line.Start != nil { + startMs := *ref.line.Start + line.Start = &startMs + } + if line.End == nil && ref.line.End != nil { + endMs := *ref.line.End + line.End = &endMs + } + line = normalizeLineTiming(line) + + if line.Value == "" && len(line.Cue) == 0 { + continue + } + + resolved = append(resolved, ttmlResolvedMetadataLine{ + order: ref.order, + seq: entry.seq, + line: line, + }) + } + + if len(resolved) == 0 { + continue + } + + sort.SliceStable(resolved, func(i, j int) bool { + if resolved[i].order != resolved[j].order { + return resolved[i].order < resolved[j].order + } + return resolved[i].seq < resolved[j].seq + }) + + lines := make([]Line, len(resolved)) + for i := range resolved { + lines[i] = resolved[i].line + } + + res = append(res, p.finalizeLyrics(Lyrics{ + Kind: kind, + Lang: lang, + Line: lines, + Synced: linesAreSynced(lines), + })) + } + + return res +} + +func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line) + return normalizeLyrics(lyrics) +} + +func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) { + if len(lines) == 0 { + return lines, nil + } + + usedOrder := make([]string, 0, 4) + usedSet := make(map[string]struct{}, 4) + sawEmptyCue := false + + for i := range lines { + for j := range lines[i].Cue { + agentID := strings.TrimSpace(lines[i].Cue[j].AgentID) + if agentID == "" { + sawEmptyCue = true + continue + } + if _, exists := usedSet[agentID]; !exists { + usedSet[agentID] = struct{}{} + usedOrder = append(usedOrder, agentID) + } + } + } + + if len(usedOrder) == 0 { + return lines, nil + } + + mainID := "" + for _, agentID := range usedOrder { + role := p.baseRoleForAgent(agentID) + if role != "bg" && role != "group" { + mainID = agentID + break + } + } + if mainID == "" && sawEmptyCue { + mainID = "main" + } + if mainID == "" { + for _, agentID := range usedOrder { + if p.baseRoleForAgent(agentID) != "bg" { + mainID = agentID + break + } + } + } + if mainID == "" { + mainID = usedOrder[0] + } + + if _, exists := usedSet[mainID]; !exists { + usedSet[mainID] = struct{}{} + usedOrder = append([]string{mainID}, usedOrder...) + } + + for i := range lines { + for j := range lines[i].Cue { + if strings.TrimSpace(lines[i].Cue[j].AgentID) == "" { + lines[i].Cue[j].AgentID = mainID + } + } + } + + agents := make([]Agent, 0, len(usedOrder)) + for _, agentID := range usedOrder { + role := p.baseRoleForAgent(agentID) + if agentID == mainID { + role = "main" + } + agent := Agent{ + ID: agentID, + Role: role, + Name: p.agentNameForID(agentID), + } + agents = append(agents, agent) + } + + return lines, agents +} + +func (p *ttmlParser) resolveCueAgentID(ctx ttmlTimingContext) string { + agentID := strings.TrimSpace(ctx.agentID) + if contextHasRole(ctx.role, "x-bg") { + if agentID == "" { + agentID = "main" + } + return backgroundAgentID(agentID) + } + return agentID +} + +func (p *ttmlParser) baseRoleForAgent(agentID string) string { + if isBackgroundAgentID(agentID) { + return "bg" + } + + if agent, ok := p.definedAgents[agentID]; ok { + switch agent.Type { + case "group": + return "group" + default: + return "voice" + } + } + + return "voice" +} + +func (p *ttmlParser) agentNameForID(agentID string) string { + if isBackgroundAgentID(agentID) { + baseID := strings.TrimPrefix(agentID, ttmlBackgroundAgentPrefix) + if baseID == "main" { + return "" + } + if agent, ok := p.definedAgents[baseID]; ok { + return agent.Name + } + return "" + } + + if agent, ok := p.definedAgents[agentID]; ok { + return agent.Name + } + + return "" +} + +func backgroundAgentID(agentID string) string { + return ttmlBackgroundAgentPrefix + agentID +} + +func isBackgroundAgentID(agentID string) bool { + return strings.HasPrefix(agentID, ttmlBackgroundAgentPrefix) +} + +func contextHasRole(roles string, role string) bool { + lowerRole := strings.ToLower(role) + for _, candidate := range strings.Fields(strings.ToLower(roles)) { + if candidate == lowerRole { + return true + } + } + return false +} + +func appendTTMLRoles(existing string, roles string) string { + for _, role := range strings.Fields(roles) { + if contextHasRole(existing, role) { + continue + } + if existing == "" { + existing = role + } else { + existing += " " + role + } + } + return existing +} + +func (p *ttmlParser) addMainLine(lang string, lineKey string, line Line) { + lang = normalizeLyricLang(lang) + if _, ok := p.mainLinesByLang[lang]; !ok { + p.mainLangOrder = append(p.mainLangOrder, lang) + } + p.mainLinesByLang[lang] = append(p.mainLinesByLang[lang], line) + + lineKey = strings.TrimSpace(lineKey) + if lineKey != "" { + if _, exists := p.mainLineRefsByKey[lineKey]; !exists { + p.mainLineRefsByKey[lineKey] = ttmlLineRef{ + order: p.mainLineOrder, + line: line, + } + } + } + p.mainLineOrder++ +} + +func (p *ttmlParser) addMetadataEntry(kind string, lang string, entry ttmlMetadataEntry) { + lang = normalizeLyricLang(lang) + entry.seq = p.metadataSeq + p.metadataSeq++ + + switch kind { + case LyricKindTranslation: + if _, ok := p.translationEntriesByLg[lang]; !ok { + p.translationLangOrder = append(p.translationLangOrder, lang) + } + p.translationEntriesByLg[lang] = append(p.translationEntriesByLg[lang], entry) + case LyricKindPronunciation: + if _, ok := p.pronunciationEntriesByLg[lang]; !ok { + p.pronunciationLangOrder = append(p.pronunciationLangOrder, lang) + } + p.pronunciationEntriesByLg[lang] = append(p.pronunciationEntriesByLg[lang], entry) + } +} + +func (p *ttmlParser) childContext(attrs []xml.Attr, parent ttmlTimingContext) ttmlTimingContext { + ctx := parent + + if lang, ok := attrValue(attrs, "lang"); ok { + ctx.lang = normalizeLyricLang(lang) + } + if agentID, ok := attrValue(attrs, "agent"); ok { + ctx.agentID = strings.TrimSpace(agentID) + } + if role, ok := attrValue(attrs, "role"); ok { + role = strings.TrimSpace(role) + if role != "" { + ctx.role = appendTTMLRoles(ctx.role, role) + } + } + + beginExpr, hasBegin := attrValue(attrs, "begin") + endExpr, hasEnd := attrValue(attrs, "end") + durExpr, hasDur := attrValue(attrs, "dur") + + if hasBegin { + begin, kind, ok := parseTTMLTimeExpression(beginExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + + base := int64(0) + if parent.hasBegin { + base = parent.begin + } + ctx.begin = resolveTTMLTime(begin, kind, base, parent) + ctx.hasBegin = true + } else { + ctx.begin = parent.begin + ctx.hasBegin = parent.hasBegin + } + + var calculatedEnd int64 + calculatedHasEnd := false + + if hasEnd { + end, kind, ok := parseTTMLTimeExpression(endExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + + base := ctx.begin + if !ctx.hasBegin { + base = parent.begin + } + calculatedEnd = resolveTTMLTime(end, kind, base, parent) + calculatedHasEnd = true + } + + if hasDur { + dur, ok := parseTTMLDurationExpression(durExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + if ctx.hasBegin { + durEnd := ctx.begin + dur + if !calculatedHasEnd || durEnd < calculatedEnd { + calculatedEnd = durEnd + calculatedHasEnd = true + } + } + } + + if !calculatedHasEnd && parent.hasEnd { + calculatedEnd = parent.end + calculatedHasEnd = true + } + + ctx.end = calculatedEnd + ctx.hasEnd = calculatedHasEnd + return ctx +} + +func (p *ttmlParser) updateTimingParams(attrs []xml.Attr) { + frameRate := p.params.frameRate + if value, ok := attrValue(attrs, "frameRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + frameRate = parsed + } + } + + if value, ok := attrValue(attrs, "frameRateMultiplier"); ok { + parts := strings.Fields(value) + if len(parts) == 2 { + numerator, errA := strconv.ParseFloat(parts[0], 64) + denominator, errB := strconv.ParseFloat(parts[1], 64) + if errA == nil && errB == nil && denominator > 0 { + frameRate = frameRate * (numerator / denominator) + } + } + } + + subFrameRate := p.params.subFrameRate + if value, ok := attrValue(attrs, "subFrameRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + subFrameRate = parsed + } + } + + tickRate := p.params.tickRate + if value, ok := attrValue(attrs, "tickRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + tickRate = parsed + } + } + + p.params.frameRate = gg.If(frameRate > 0, frameRate, defaultTTMLFrameRate) + p.params.subFrameRate = gg.If(subFrameRate > 0, subFrameRate, defaultTTMLSubFrameRate) + p.params.tickRate = gg.If(tickRate > 0, tickRate, defaultTTMLTickRate) +} + +func parseTTMLDurationExpression(expr string, params ttmlTimingParams) (int64, bool) { + value, _, ok := parseTTMLTimeExpression(expr, params) + return value, ok +} + +func resolveTTMLTime(value int64, kind ttmlTimeKind, base int64, parent ttmlTimingContext) int64 { + switch kind { + case ttmlTimeAbsolute: + return value + case ttmlTimeOffset: + return base + value + case ttmlTimeAmbiguous: + absolute := value + offset := base + value + + // No parent timing context → no reference frame for offsets. + // Prefer absolute when offset differs (i.e., base > 0). + if !parent.hasBegin && !parent.hasEnd && base != 0 { + return absolute + } + + if parent.hasBegin && parent.hasEnd { + absoluteInParent := absolute >= parent.begin && absolute <= parent.end + offsetInParent := offset >= parent.begin && offset <= parent.end + if absoluteInParent && !offsetInParent { + return absolute + } + if offsetInParent && !absoluteInParent { + return offset + } + } + + if parent.hasBegin { + if absolute < parent.begin && offset >= parent.begin { + return offset + } + if absolute >= parent.begin && offset > absolute { + return absolute + } + } + return offset + default: + return base + value + } +} + +func parseTTMLTimeExpression(expr string, params ttmlTimingParams) (int64, ttmlTimeKind, bool) { + expr = strings.TrimSpace(expr) + if expr == "" { + return 0, ttmlTimeOffset, false + } + + lower := strings.ToLower(expr) + if strings.Contains(lower, "wallclock(") || + strings.Contains(lower, ".begin") || + strings.Contains(lower, ".end") { + log.Warn("Unsupported TTML time expression", "value", expr) + return 0, ttmlTimeOffset, false + } + + // Best-effort support for non-standard TTML seen in the wild where a + // bare decimal value is used (implicitly seconds), e.g. "0.170". + if value, err := strconv.ParseFloat(lower, 64); err == nil && value >= 0 { + return int64(math.Round(value * 1000)), ttmlTimeAmbiguous, true + } + + if matches := offsetTimeRegex.FindStringSubmatch(lower); len(matches) == 3 { + value, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return 0, ttmlTimeOffset, false + } + + unit := matches[2] + seconds := 0.0 + switch unit { + case "h": + seconds = value * 60 * 60 + case "m": + seconds = value * 60 + case "s": + seconds = value + case "ms": + seconds = value / 1000 + case "f": + seconds = value / params.frameRate + case "t": + seconds = value / params.tickRate + default: + return 0, ttmlTimeOffset, false + } + + return int64(math.Round(seconds * 1000)), ttmlTimeOffset, true + } + + colonCount := strings.Count(expr, ":") + switch colonCount { + case 1, 2: + clockMs, ok := parseTTMLClockTime(expr) + if !ok { + return 0, ttmlTimeAbsolute, false + } + return clockMs, ttmlTimeAbsolute, true + case 3: + framesMs, ok := parseTTMLFrameTime(expr, params) + if !ok { + return 0, ttmlTimeAbsolute, false + } + return framesMs, ttmlTimeAbsolute, true + default: + log.Warn("Unsupported TTML time expression", "value", expr) + return 0, ttmlTimeOffset, false + } +} + +func parseTTMLClockTime(value string) (int64, bool) { + parts := strings.Split(value, ":") + if len(parts) != 2 && len(parts) != 3 { + return 0, false + } + + hours := int64(0) + minutesIdx := 0 + if len(parts) == 3 { + h, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false + } + hours = h + minutesIdx = 1 + } + + minutes, err := strconv.ParseInt(parts[minutesIdx], 10, 64) + if err != nil { + return 0, false + } + + seconds, err := strconv.ParseFloat(parts[minutesIdx+1], 64) + if err != nil { + return 0, false + } + + totalSeconds := float64(hours*60*60+minutes*60) + seconds + return int64(math.Round(totalSeconds * 1000)), true +} + +func parseTTMLFrameTime(value string, params ttmlTimingParams) (int64, bool) { + parts := strings.Split(value, ":") + if len(parts) != 4 { + return 0, false + } + + hours, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false + } + + minutes, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return 0, false + } + + seconds, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return 0, false + } + + frameParts := strings.SplitN(parts[3], ".", 2) + frames, err := strconv.ParseFloat(frameParts[0], 64) + if err != nil { + return 0, false + } + + subFrames := 0.0 + if len(frameParts) == 2 { + subFrames, err = strconv.ParseFloat(frameParts[1], 64) + if err != nil { + return 0, false + } + } + + totalSeconds := float64(hours*60*60 + minutes*60 + seconds) + totalSeconds += frames / params.frameRate + totalSeconds += subFrames / (params.subFrameRate * params.frameRate) + + return int64(math.Round(totalSeconds * 1000)), true +} + +func attrValue(attrs []xml.Attr, key string) (string, bool) { + for _, attr := range attrs { + if strings.EqualFold(attr.Name.Local, key) { + return strings.TrimSpace(attr.Value), true + } + } + return "", false +} + +func attrOrEmpty(attrs []xml.Attr, key string) string { + value, _ := attrValue(attrs, key) + return value +} + +func (p *ttmlParser) collectElementText(start xml.StartElement) (string, error) { + var text strings.Builder + + for { + token, err := p.decoder.Token() + if err != nil { + return "", err + } + + switch t := token.(type) { + case xml.StartElement: + value, err := p.collectElementText(t) + if err != nil { + return "", err + } + text.WriteString(value) + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return text.String(), nil + } + case xml.CharData: + text.WriteString(string(t)) + } + } +} + +func (p *ttmlParser) skipElement(_ xml.StartElement) error { + depth := 1 + for depth > 0 { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch token.(type) { + case xml.StartElement: + depth++ + case xml.EndElement: + depth-- + } + } + return nil +} + +func normalizeLyricLang(lang string) string { + lang = strings.ToLower(strings.TrimSpace(lang)) + if lang == "" { + return "xxx" + } + return lang +} + +func sanitizeTTMLText(raw string) string { + raw = str.SanitizeText(raw) + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + lines := strings.Split(raw, "\n") + for i := range lines { + lines[i] = strings.TrimSpace(lines[i]) + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func linesAreSynced(lines []Line) bool { + for i := range lines { + if lines[i].Start != nil { + return true + } + for j := range lines[i].Cue { + if lines[i].Cue[j].Start != nil { + return true + } + } + } + return false +} diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go new file mode 100644 index 000000000..bbdf5c7c4 --- /dev/null +++ b/model/lyrics_ttml_test.go @@ -0,0 +1,551 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseTTML", func() { + Describe("Multi-language and timing", func() { + It("should parse multiple language divs with inherited offsets and frame/tick timing", func() { + content := []byte(` + + +
+

Line one

+

Line two
with break

+
+
+

Linha

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(2)) + + By("parsing the English track") + eng := list[0] + Expect(eng.Lang).To(Equal("eng")) + Expect(eng.Synced).To(BeTrue()) + Expect(eng.Line[0].Start).To(Equal(new(int64(3000)))) + Expect(eng.Line[0].Value).To(Equal("Line one")) + Expect(eng.Line[1].Start).To(Equal(new(int64(4517)))) + Expect(eng.Line[1].Value).To(Equal("Line two\nwith break")) + + By("parsing the Portuguese track") + por := list[1] + Expect(por.Lang).To(Equal("por")) + Expect(por.Line[0].Start).To(Equal(new(int64(4500)))) + Expect(por.Line[0].Value).To(Equal("Linha")) + }) + }) + + Describe("Unsupported cue handling", func() { + It("should skip wallclock cues and keep valid ones", func() { + content := []byte(` + + +
+

Skip me

+

Keep me

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(1000)))) + Expect(list[0].Line[0].Value).To(Equal("Keep me")) + }) + }) + + Describe("Begin/End/Dur with inheritance", func() { + It("should correctly accumulate nested timing from body, div, and p elements", func() { + content := []byte(` + + +
+

First line

+

Second line

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(16000)))) + Expect(list[0].Line[0].Value).To(Equal("First line")) + Expect(list[0].Line[1].Start).To(Equal(new(int64(18000)))) + Expect(list[0].Line[1].Value).To(Equal("Second line")) + }) + }) + + Describe("Non-standard bare second offsets", func() { + It("should parse bare decimal numbers as seconds", func() { + content := []byte(` + + +
+

First line

+

Second line

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(10170)))) + Expect(list[0].Line[0].Value).To(Equal("First line")) + Expect(list[0].Line[1].Start).To(Equal(new(int64(13710)))) + Expect(list[0].Line[1].Value).To(Equal("Second line")) + }) + }) + + Describe("Word timing tokens", func() { + It("should extract timed tokens from spans including background role", func() { + content := []byte(` + + +
+

+ Hello + echo +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Start).To(Equal(new(int64(1000)))) + Expect(line.Value).To(Equal("Hello echo")) + Expect(line.End).To(Equal(new(int64(3000)))) + Expect(line.Cue).To(HaveLen(3)) + + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(1000)), End: new(int64(1400)), Value: "He", ByteStart: 0, ByteEnd: 1, AgentID: "main"})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(1400)), End: new(int64(1800)), Value: "llo", ByteStart: 2, ByteEnd: 4, AgentID: "main"})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2500)), Value: "echo", ByteStart: 6, ByteEnd: 9, AgentID: "__nd_bg__|main"})) + }) + + It("should append role tokens exactly instead of using substring matches", func() { + content := []byte(` + + +
+

LeadEcho

+
+ +
`) + + list, err := parseTTML("xxx", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("main")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|main")) + }) + + It("should parse named TTML agents into main, voice, and group roles", func() { + content := []byte(` + + + + Chris Martin + Jin + All + + + +
+

You

+

and

+

All

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "v1", Role: "main", Name: "Chris Martin"}, + {ID: "v2", Role: "voice", Name: "Jin"}, + {ID: "v1000", Role: "group", Name: "All"}, + })) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("v1")) + Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("v2")) + Expect(list[0].Line[2].Cue[0].AgentID).To(Equal("v1000")) + }) + + It("should avoid collisions between derived background agents and explicit TTML agent ids", func() { + content := []byte(` + + + + Lead + Existing Background Id + + + +
+

+ Lead + Echo +

+

+ Named +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "lead", Role: "main", Name: "Lead"}, + {ID: "__nd_bg__|lead", Role: "bg", Name: "Lead"}, + {ID: "lead__bg", Role: "voice", Name: "Existing Background Id"}, + })) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("lead")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|lead")) + Expect(list[0].Line[1].Cue).To(HaveLen(1)) + Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("lead__bg")) + }) + + It("should fill missing cue agent ids with the resolved main agent", func() { + content := []byte(` + + + + Guest Vocal + + + +
+

+ Lead + Guest +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "guest", Role: "main", Name: "Guest Vocal"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("guest")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("guest")) + }) + }) + + Describe("Whitespace handling", func() { + It("should collapse pretty-print indentation between spans into single spaces, not line breaks", func() { + content := []byte(` + + +
+

+ It + in, + + (When you + slide) + +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Value).To(Equal("It in, (When you slide)")) + Expect(line.Value).ToNot(ContainSubstring("\n")) + Expect(line.Cue).To(HaveLen(4)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(82889)), End: new(int64(83127)), Value: "It", ByteStart: 0, ByteEnd: 1, AgentID: "v2"})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(83374)), End: new(int64(83938)), Value: "in,", ByteStart: 3, ByteEnd: 5, AgentID: "v2"})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(83881)), End: new(int64(84243)), Value: "(When you", ByteStart: 7, ByteEnd: 15, AgentID: "__nd_bg__|v2"})) + Expect(line.Cue[3]).To(Equal(Cue{Start: new(int64(86232)), End: new(int64(86859)), Value: "slide)", ByteStart: 17, ByteEnd: 22, AgentID: "__nd_bg__|v2"})) + }) + + It("should preserve explicit
as a line break", func() { + content := []byte(` + + +
+

+ first +
+ second +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("first\nsecond")) + }) + + It("should only collapse XML whitespace, leaving other Unicode spaces intact", func() { + // Whitespace collapsing only touches the XML S characters + // (space/tab/CR/LF). Other Unicode spaces like U+3000 are left as-is: + // the U+3000 inside a span survives, while the pretty-print newline + // between spans still collapses to a single space. + content := []byte("\n" + + "\n" + + " \n" + + "
\n" + + "

\n" + + " あ い\n" + + " う\n" + + "

\n" + + "
\n" + + " \n" + + "
") + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("あ い う")) + Expect(list[0].Line[0].Cue[0].Value).To(Equal("あ い")) + }) + }) + + Describe("Interleaved background cue timing", func() { + It("should not corrupt a main cue's end time when a background cue is earlier in time", func() { + // Background spans (x-bg) appear after the main spans in document order + // but their timings interleave with the main timeline. End-time + // normalization must be per agent so the last main cue keeps its real + // end instead of collapsing to its own start. + content := []byte(` + + +
+

real slow (When you slide)

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Cue).To(HaveLen(4)) + + cuesByAgent := map[string][]Cue{} + for _, c := range line.Cue { + cuesByAgent[c.AgentID] = append(cuesByAgent[c.AgentID], c) + } + + mainCues := cuesByAgent["v2"] + Expect(mainCues).To(HaveLen(2)) + Expect(*mainCues[0].End).To(Equal(int64(85934))) // "real" + // "slow" must keep its real end (86751), not collapse to its start. + Expect(*mainCues[1].Start).To(Equal(int64(85934))) + Expect(*mainCues[1].End).To(Equal(int64(86751))) + + bgCues := cuesByAgent["__nd_bg__|v2"] + Expect(bgCues).To(HaveLen(2)) + Expect(*bgCues[0].End).To(Equal(int64(84243))) // "(When you" + Expect(*bgCues[1].End).To(Equal(int64(86859))) // "slide)" + }) + }) + + Describe("Ambiguous decimal timing", func() { + It("should prefer absolute timing when values fall inside parent window", func() { + content := []byte(` + + +
+

+ go + go +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Start).To(Equal(new(int64(43444)))) + Expect(line.Value).To(Equal("go go")) + Expect(line.End).To(Equal(new(int64(45570)))) + Expect(line.Cue).To(HaveLen(2)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(43444)), End: new(int64(43716)), Value: "go", ByteStart: 0, ByteEnd: 1})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(43716)), End: new(int64(43887)), Value: "go", ByteStart: 3, ByteEnd: 4})) + }) + }) + + Describe("Unsynced fallback", func() { + It("should return unsynced lyrics when no timing is present", func() { + content := []byte(` + + +
+

No timing here

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("xxx")) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Start).To(BeNil()) + Expect(list[0].Line[0].Value).To(Equal("No timing here")) + }) + }) + + Describe("Metadata tracks", func() { + It("should produce main, translation, and pronunciation tracks from iTunesMetadata", func() { + content := []byte(` + + + + + + + Hola + Skip me + + + + + konni + + + + + + +
+

こんにちは

+

こんばんは

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(3)) + + By("checking the main track") + main := list[0] + Expect(main.Kind).To(Equal("main")) + Expect(main.Lang).To(Equal("ja")) + Expect(main.Line).To(HaveLen(2)) + + By("checking the translation track") + translation := list[1] + Expect(translation.Kind).To(Equal("translation")) + Expect(translation.Lang).To(Equal("es")) + Expect(translation.Line).To(HaveLen(1)) + Expect(translation.Line[0].Start).To(Equal(new(int64(1000)))) + Expect(translation.Line[0].Value).To(Equal("Hola")) + Expect(translation.Line[0].End).To(Equal(new(int64(1500)))) + + By("checking the pronunciation track") + pronunciation := list[2] + Expect(pronunciation.Kind).To(Equal("pronunciation")) + Expect(pronunciation.Lang).To(Equal("ja-latn")) + Expect(pronunciation.Line).To(HaveLen(1)) + Expect(pronunciation.Line[0].Start).To(Equal(new(int64(2000)))) + Expect(pronunciation.Line[0].Value).To(Equal("konni")) + Expect(pronunciation.Line[0].End).To(Equal(new(int64(2600)))) + Expect(pronunciation.Line[0].Cue).To(HaveLen(2)) + Expect(pronunciation.Line[0].Cue[0]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2300)), Value: "ko", ByteStart: 0, ByteEnd: 1})) + Expect(pronunciation.Line[0].Cue[1]).To(Equal(Cue{Start: new(int64(2300)), End: new(int64(2600)), Value: "nni", ByteStart: 2, ByteEnd: 4})) + }) + }) + + Describe("Pronunciation with bare decimal end times", func() { + It("should correctly parse bare decimal times in transliteration spans", func() { + content := []byte(` + + + + + + + I woke up + + + + + + +
+

起きた

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + + var pronunciation *Lyrics + for i := range list { + if list[i].Kind == "pronunciation" { + pronunciation = &list[i] + break + } + } + Expect(pronunciation).ToNot(BeNil()) + Expect(pronunciation.Line).To(HaveLen(1)) + + line := pronunciation.Line[0] + Expect(line.Start).To(Equal(new(int64(2747)))) + Expect(line.Value).To(Equal("I woke up")) + Expect(line.Cue).To(HaveLen(3)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(2747)), End: new(int64(3018)), Value: "I", ByteStart: 0, ByteEnd: 0})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(3018)), End: new(int64(3179)), Value: "woke", ByteStart: 2, ByteEnd: 5})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(3179)), End: new(int64(3582)), Value: "up", ByteStart: 7, ByteEnd: 8})) + }) + }) +}) diff --git a/model/mediafile.go b/model/mediafile.go index 6a489bcd5..0fd172cee 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/slice" ) @@ -138,7 +139,7 @@ func (mf MediaFile) AlbumCoverArtID() ArtworkID { } func (mf MediaFile) StructuredLyrics() (LyricList, error) { - lyrics := LyricList{} + var lyrics LyricList err := json.Unmarshal([]byte(mf.Lyrics), &lyrics) if err != nil { return nil, err @@ -151,6 +152,55 @@ func (mf MediaFile) String() string { return mf.Path } +type Work struct { + Name string + MbzWorkID string +} + +type Movement struct { + Name string + Number int32 + Count int32 +} + +func (mf MediaFile) Works() []Work { + names := mf.Tags.Values(TagWork) + if len(names) == 0 { + return nil + } + ids := mf.Tags.Values(TagMusicBrainzWorkID) + works := make([]Work, 0, len(names)) + for i, name := range names { + w := Work{Name: name} + if i < len(ids) { + w.MbzWorkID = ids[i] + } + works = append(works, w) + } + return works +} + +func (mf MediaFile) Movements() []Movement { + names := mf.Tags.Values(TagMovementName) + if len(names) == 0 { + return nil + } + numbers := mf.Tags.Values(TagMovementNumber) + counts := mf.Tags.Values(TagMovementTotal) + movements := make([]Movement, 0, len(names)) + for i, name := range names { + m := Movement{Name: name} + if i < len(numbers) { + m.Number = number.ParseInt[int32](numbers[i]) + } + if i < len(counts) { + m.Count = number.ParseInt[int32](counts[i]) + } + movements = append(movements, m) + } + return movements +} + // Hash returns a hash of the MediaFile based on its tags and audio properties func (mf MediaFile) Hash() string { opts := &hashstructure.HashOptions{ diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 65c5a0652..f070f4649 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -604,6 +604,69 @@ var _ = Describe("MediaFile", func() { }) +var _ = Describe("MediaFile.Works", func() { + It("returns nil when there are no work tags", func() { + mf := MediaFile{} + Expect(mf.Works()).To(BeNil()) + }) + + It("pairs a work name with its MbzWorkID", func() { + mf := MediaFile{Tags: Tags{ + TagWork: {"Symphony No. 5"}, + TagMusicBrainzWorkID: {"abc-123"}, + }} + Expect(mf.Works()).To(Equal([]Work{ + {Name: "Symphony No. 5", MbzWorkID: "abc-123"}, + })) + }) + + It("leaves MbzWorkID empty when no id is present", func() { + mf := MediaFile{Tags: Tags{TagWork: {"Symphony No. 5"}}} + Expect(mf.Works()).To(Equal([]Work{ + {Name: "Symphony No. 5"}, + })) + }) + + It("pairs by index and ignores extra ids", func() { + mf := MediaFile{Tags: Tags{ + TagWork: {"Work A", "Work B"}, + TagMusicBrainzWorkID: {"id-a"}, + }} + Expect(mf.Works()).To(Equal([]Work{ + {Name: "Work A", MbzWorkID: "id-a"}, + {Name: "Work B"}, + })) + }) +}) + +var _ = Describe("MediaFile.Movements", func() { + It("returns nil when there are no movement tags", func() { + mf := MediaFile{} + Expect(mf.Movements()).To(BeNil()) + }) + + It("builds a movement with name, number and count", func() { + mf := MediaFile{Tags: Tags{ + TagMovementName: {"I. Allegro"}, + TagMovementNumber: {"1"}, + TagMovementTotal: {"4"}, + }} + Expect(mf.Movements()).To(Equal([]Movement{ + {Name: "I. Allegro", Number: 1, Count: 4}, + })) + }) + + It("non-numeric number/count yields 0", func() { + mf := MediaFile{Tags: Tags{ + TagMovementName: {"I. Allegro"}, + TagMovementNumber: {"not-a-number"}, + }} + Expect(mf.Movements()).To(Equal([]Movement{ + {Name: "I. Allegro"}, + })) + }) +}) + var _ = Describe("MediaFile.Hash", func() { // Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes, // or every file would be spuriously re-imported on the next scan. diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 966a545be..de2ba813e 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -143,13 +143,15 @@ func (md Metadata) mapLyrics() string { lang := raw.Key() text := raw.Value() - lyrics, err := model.ToLyrics(lang, text) + lyrics, err := model.ParseLyrics("", lang, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err) continue } - if !lyrics.IsEmpty() { - lyricList = append(lyricList, *lyrics) + for _, lyric := range lyrics { + if !lyric.IsEmpty() { + lyricList = append(lyricList, lyric) + } } } diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 350731b89..7ebe9fa4a 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -105,7 +105,7 @@ var _ = Describe("Metadata", func() { props.Tags = model.RawTags{ "Title": {strings.Repeat("a", 2048)}, "Comment": {strings.Repeat("a", 8192)}, - "lyrics:xxx": {strings.Repeat("a", 60000)}, + "lyrics:xxx": {strings.Repeat("a", 2_000_000)}, } md = metadata.New(filePath, props) @@ -116,9 +116,10 @@ var _ = Describe("Metadata", func() { Expect(pair).To(HaveLen(1)) Expect(pair[0].Key()).To(Equal("xxx")) + // Lyrics keep a much larger cap so word-timed karaoke survives. // Note: a total of 6 characters are lost from maxLength from - // the key portion and separator - Expect(pair[0].Value()).To(HaveLen(32762)) + // the key portion and separator. + Expect(pair[0].Value()).To(HaveLen(1048570)) }) It("should split multiple values", func() { diff --git a/model/tag.go b/model/tag.go index 1f6b24d21..02ccac05d 100644 --- a/model/tag.go +++ b/model/tag.go @@ -192,6 +192,10 @@ const ( TagISRC TagName = "isrc" TagBPM TagName = "bpm" TagExplicitStatus TagName = "explicitstatus" + TagWork TagName = "work" + TagMovementName TagName = "movementname" + TagMovementNumber TagName = "movement" + TagMovementTotal TagName = "movementtotal" // Dates and years @@ -240,6 +244,7 @@ const ( TagMusicBrainzAlbumArtistID TagName = "musicbrainz_albumartistid" TagMusicBrainzAlbumID TagName = "musicbrainz_albumid" TagMusicBrainzReleaseGroupID TagName = "musicbrainz_releasegroupid" + TagMusicBrainzWorkID TagName = "musicbrainz_workid" TagMusicBrainzComposerID TagName = "musicbrainz_composerid" TagMusicBrainzLyricistID TagName = "musicbrainz_lyricistid" diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 99ed10877..31a0f5c91 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -143,9 +143,9 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { func recentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "datetime(album.updated_at)" + return "album.updated_at, album.id" } - return "datetime(album.created_at)" + return "album.created_at, album.id" } func recentlyPlayedFilter(string, any) Sqlizer { @@ -186,8 +186,10 @@ func allRolesFilter(_ string, value any) Sqlizer { func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() - query = r.withAnnotation(query, "album.id") query = r.applyLibraryFilter(query) + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "album.id") + } return r.count(query, options...) } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index a6270933f..f72f778db 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -112,49 +112,66 @@ var _ = Describe("AlbumRepository", func() { }) Describe("recently_added sort", func() { - It("sorts correctly regardless of timestamp format (T-format vs space-format)", func() { - // Both timestamps share the same date prefix "2024-01-15" so the T vs space - // character at position 10 determines sort order in raw string comparison. - // Without normalization, 'T' (ASCII 84) > ' ' (ASCII 32) makes the older - // T-format timestamp sort AFTER the newer space-format one. + AfterEach(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album"). + Where(squirrel.Like{"id": "ra-%"})) + }) - // Older album: morning of Jan 15, stored in T-format - olderAlbum := &model.Album{LibraryID: 1, ID: "ts-older", Name: "Older Album"} - Expect(albumRepo.Put(olderAlbum)).To(Succeed()) + // Sub-second precision must survive, and ties must break deterministically + // so the order is independent of any filter (issue #5673). + indexOf := func(albums model.Albums, id string) int { + for i, a := range albums { + if a.ID == id { + return i + } + } + return -1 + } + + It("orders by sub-second precision, not truncated to the second", func() { + // Same second, different nanoseconds: datetime() would tie these. + earlier := &model.Album{LibraryID: 1, ID: "ra-earlier", Name: "Earlier"} + later := &model.Album{LibraryID: 1, ID: "ra-later", Name: "Later"} + Expect(albumRepo.Put(earlier)).To(Succeed()) + Expect(albumRepo.Put(later)).To(Succeed()) _, err := albumRepo.executeSQL(squirrel.Update("album"). - Set("created_at", "2024-01-15T08:00:00Z"). - Where(squirrel.Eq{"id": "ts-older"})) + Set("created_at", "2024-01-15 10:00:00.100000000+00:00"). + Where(squirrel.Eq{"id": "ra-earlier"})) Expect(err).ToNot(HaveOccurred()) - - // Newer album: evening of Jan 15, stored in space-format - newerAlbum := &model.Album{LibraryID: 1, ID: "ts-newer", Name: "Newer Album"} - Expect(albumRepo.Put(newerAlbum)).To(Succeed()) _, err = albumRepo.executeSQL(squirrel.Update("album"). - Set("created_at", "2024-01-15 20:00:00+00:00"). - Where(squirrel.Eq{"id": "ts-newer"})) + Set("created_at", "2024-01-15 10:00:00.900000000+00:00"). + Where(squirrel.Eq{"id": "ra-later"})) Expect(err).ToNot(HaveOccurred()) albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) Expect(err).ToNot(HaveOccurred()) + Expect(indexOf(albums, "ra-later")).To(BeNumerically("<", indexOf(albums, "ra-earlier")), + ".900 should sort before .100 in desc order") + }) - // Find positions of our test albums - olderIdx, newerIdx := -1, -1 - for i, a := range albums { - switch a.ID { - case "ts-older": - olderIdx = i - case "ts-newer": - newerIdx = i - } + It("breaks ties deterministically and consistently across filters", func() { + // All sharing one created_at: the relative order of any subset must + // match the unfiltered order (the inversion mechanism in #5673). + ids := []string{"ra-t1", "ra-t2", "ra-t3", "ra-t4"} + for _, aid := range ids { + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: aid, Name: aid})).To(Succeed()) } - Expect(olderIdx).To(BeNumerically(">=", 0), "older album not found in results") - Expect(newerIdx).To(BeNumerically(">=", 0), "newer album not found in results") - // Newer album (evening, space-format) should come before older album (morning, T-format) in desc order - Expect(newerIdx).To(BeNumerically("<", olderIdx), - "Newer album (20:00 space-format) should sort before older album (08:00 T-format) in desc order") + _, err := albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-02-20 12:00:00+00:00"). + Where(squirrel.Eq{"id": ids})) + Expect(err).ToNot(HaveOccurred()) - // Clean up - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"ts-older", "ts-newer"}})) + all, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) + Expect(err).ToNot(HaveOccurred()) + + subset, err := albumRepo.GetAll(model.QueryOptions{ + Sort: "recently_added", Order: "desc", + Filters: squirrel.Eq{"album.id": []string{"ra-t1", "ra-t3"}}}) + Expect(err).ToNot(HaveOccurred()) + + Expect(indexOf(all, "ra-t1") < indexOf(all, "ra-t3")). + To(Equal(indexOf(subset, "ra-t1") < indexOf(subset, "ra-t3")), + "tied albums must keep the same relative order with and without a filter") }) }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 56843b911..5152e774f 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -201,7 +201,11 @@ func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBui func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() query = r.applyLibraryFilterToArtistQuery(query) - query = r.withAnnotation(query, "artist.id") + // Only the annotation join is gated; the library_artist join above (and its count(distinct)) + // must stay, since an artist can span multiple libraries. + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "artist.id") + } return r.count(query, options...) } @@ -664,32 +668,6 @@ func isLibraryIDFilter(filter Sqlizer) bool { return ok } -// userSeesAllLibraries reports whether the visible set already covers every library, so a search -// needs no library filter at all. -func (r *artistRepository) userSeesAllLibraries(visible []int) bool { - user := loggedUser(r.ctx) - if user.IsAdmin || user.ID == invalidUserId { - return true // visible is the whole library table - } - total, err := NewLibraryRepository(r.ctx, r.db).CountAll() - if err != nil || total == 0 { - return false - } - return int64(len(visible)) >= total -} - -// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and -// headless processes, otherwise the user's granted libraries. -func (r *artistRepository) visibleLibraryIDs() ([]int, error) { - user := loggedUser(r.ctx) - if user.IsAdmin || user.ID == invalidUserId { - var ids []int - err := r.queryAllSlice(Select("id").From("library"), &ids) - return ids, err - } - return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil -} - func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(r.ctx, options...)) } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 603c5dd5e..d7b695ade 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -273,6 +273,23 @@ var _ = Describe("ArtistRepository", func() { It("returns the number of artists in the DB", func() { Expect(repo.CountAll()).To(Equal(int64(4))) }) + + It("counts starred artists when an annotation filter is present", func() { + // The Beatles (id 3) is starred for the admin user in the seed data + count, err := repo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "true"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + }) + + It("counts with has_rating=false without a 'no such column' error (join kept)", func() { + count, err := repo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("rating")("rating", "false"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(4))) + }) }) Describe("Exists", func() { diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 86f5fc985..6308082d8 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -28,10 +28,11 @@ func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { } type smartPlaylistField struct { - expr string - order string - joinType smartPlaylistJoinType - emptyValues []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics) + expr string + order string + joinType smartPlaylistJoinType + emptyValues []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics) + coalesceDefault any // missing-row default for a nullable annotation column; nil = none. See annotationCond. } type smartPlaylistCriteria struct { @@ -89,22 +90,22 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "samplerate": {expr: "media_file.sample_rate"}, "bpm": {expr: "media_file.bpm"}, "channels": {expr: "media_file.channels"}, - "loved": {expr: "COALESCE(annotation.starred, false)"}, + "loved": {expr: "annotation.starred", coalesceDefault: false}, "dateloved": {expr: "annotation.starred_at"}, "lastplayed": {expr: "annotation.play_date"}, "daterated": {expr: "annotation.rated_at"}, - "playcount": {expr: "COALESCE(annotation.play_count, 0)"}, - "rating": {expr: "COALESCE(annotation.rating, 0)"}, + "playcount": {expr: "annotation.play_count", coalesceDefault: 0}, + "rating": {expr: "annotation.rating", coalesceDefault: 0}, "averagerating": {expr: "media_file.average_rating"}, - "albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, - "albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation}, - "albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumrating": {expr: "album_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation}, + "albumloved": {expr: "album_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinAlbumAnnotation}, + "albumplaycount": {expr: "album_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation}, "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, - "artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation}, - "artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation}, - "artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistrating": {expr: "artist_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, + "artistloved": {expr: "artist_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinArtistAnnotation}, + "artistplaycount": {expr: "artist_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation}, "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation}, "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation}, @@ -152,27 +153,17 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } return mergeJsonConds(or), nil case criteria.Is: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Eq(fields) - }, false) + return comparisonExpr(e, cmpEq) case criteria.IsNot: return isNotExpr(e) case criteria.Gt: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Gt(fields) - }, false) + return comparisonExpr(e, cmpGt) case criteria.Lt: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Lt(fields) - }, false) + return comparisonExpr(e, cmpLt) case criteria.Before: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Lt(fields) - }, false) + return comparisonExpr(e, cmpLt) case criteria.After: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Gt(fields) - }, false) + return comparisonExpr(e, cmpGt) case criteria.Contains: return likeExpr(e, "%%%v%%", false) case criteria.NotContains: @@ -204,11 +195,7 @@ func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, squirrel.Eq{"value": value}, true), nil } - fields, err := sqlFields(values) - if err != nil { - return nil, err - } - return squirrel.NotEq(fields), nil + return comparisonExpr(values, cmpNe) } func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) { @@ -259,20 +246,17 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er } } -func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { - if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { - return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil - } - fields, err := sqlFields(values) - if err != nil { - return nil, err - } - return makeCond(fields), nil -} - func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) { - if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { - return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + if _, value, info, ok := singleField(values); ok { + if info.IsTag || info.IsRole { + return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + } + // LIKE can't use the column index, so annotation fields keep the COALESCE form: a NULL + // column never matches LIKE, which would silently drop missing-annotation rows the original + // COALESCE form included. + if f, isAnnotation := annotationField(info); isAnnotation { + return likeCond(f.coalesced(), fmt.Sprintf(pattern, value), negate), nil + } } fields, err := sqlFields(values) if err != nil { @@ -292,23 +276,36 @@ func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqli return lk, nil } +func likeCond(col, pattern string, negate bool) squirrel.Sqlizer { + if negate { + return squirrel.NotLike{col: pattern} + } + return squirrel.Like{col: pattern} +} + func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) { - fields, err := sqlFields(values) + field, value, info, ok := singleField(values) + if !ok { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if info.IsTag || info.IsRole { + // Tags/roles are multi-valued JSON, so splitting a range into two independent EXISTS + // subqueries would let different values satisfy each bound. Ranges are unsupported there. + return nil, fmt.Errorf("range operator not supported for tag/role field: %s", field) + } + s := reflect.ValueOf(value) + if s.Kind() != reflect.Slice || s.Len() != 2 { + return nil, fmt.Errorf("range criteria for %q must be a [min, max] pair, got: %v", field, value) + } + low, err := comparisonExpr(map[string]any{field: s.Index(0).Interface()}, cmpGe) if err != nil { return nil, err } - and := squirrel.And{} - for field, value := range fields { - s := reflect.ValueOf(value) - if s.Kind() != reflect.Slice || s.Len() != 2 { - return nil, fmt.Errorf("invalid range for 'in' operator: %s", value) - } - and = append(and, - squirrel.GtOrEq{field: s.Index(0).Interface()}, - squirrel.LtOrEq{field: s.Index(1).Interface()}, - ) + high, err := comparisonExpr(map[string]any{field: s.Index(1).Interface()}, cmpLe) + if err != nil { + return nil, err } - return and, nil + return squirrel.And{low, high}, nil } func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) { @@ -642,6 +639,139 @@ func fieldExpr(name string) (string, bool) { return field.expr, ok } +// comparator is a scalar SQL comparison operator used by smart playlist criteria. +type comparator struct { + build func(map[string]any) squirrel.Sqlizer + satisfy func(a, b float64) bool // the operator as a predicate, to reason about a column's COALESCE default + // ordering is false only for = and <>, the only operators with a clean bare form over bool columns. + ordering bool +} + +var ( + cmpEq = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Eq(f) }, satisfy: func(a, b float64) bool { return a == b }} + cmpNe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.NotEq(f) }, satisfy: func(a, b float64) bool { return a != b }} + cmpGt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Gt(f) }, satisfy: func(a, b float64) bool { return a > b }, ordering: true} + cmpGe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.GtOrEq(f) }, satisfy: func(a, b float64) bool { return a >= b }, ordering: true} + cmpLt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Lt(f) }, satisfy: func(a, b float64) bool { return a < b }, ordering: true} + cmpLe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.LtOrEq(f) }, satisfy: func(a, b float64) bool { return a <= b }, ordering: true} +) + +// annotationField returns the field definition only for nullable annotation columns that have a +// COALESCE default (playcount, rating, loved). Date annotation columns (lastplayed, dateloved, ...) +// have no default and return ok=false. +func annotationField(info criteria.FieldInfo) (smartPlaylistField, bool) { + f, ok := smartPlaylistFields[info.Name()] + if !ok || f.coalesceDefault == nil { + return smartPlaylistField{}, false + } + return f, true +} + +// coalesced wraps the field in COALESCE(col, default) (bare expression if it has no default). Used +// where index-friendliness does not apply (ORDER BY, list comparisons) and the missing-row-as-default +// semantics must be kept. +func (f smartPlaylistField) coalesced() string { + if f.coalesceDefault == nil { + return f.expr + } + return fmt.Sprintf("COALESCE(%s, %s)", f.expr, sqlLiteral(f.coalesceDefault)) +} + +func comparisonExpr(values map[string]any, cmp comparator) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok { + if info.IsTag || info.IsRole { + return jsonExpr(info, cmp.build(map[string]any{"value": value}), false), nil + } + if f, isAnnotation := annotationField(info); isAnnotation { + return annotationCond(f, cmp, value), nil + } + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return cmp.build(fields), nil +} + +// annotationCond builds a comparison against a nullable annotation column (see smartPlaylistField +// for why). When bareNullInclusion can reason about the comparison exactly it emits the +// index-friendly bare `col ?`, adding `OR col IS NULL` only when the default would match; +// otherwise it falls back to the COALESCE form, which can't use the index but is always equivalent. +func annotationCond(f smartPlaylistField, cmp comparator, value any) squirrel.Sqlizer { + wrapNull, ok := bareNullInclusion(f.coalesceDefault, cmp, value) + if !ok { + return cmp.build(map[string]any{f.coalesced(): value}) + } + base := cmp.build(map[string]any{f.expr: value}) + if !wrapNull { + return base + } + // The default satisfies the predicate, so missing rows (NULL) must be included. All comparators + // (including <>) evaluate to false against NULL, so an explicit IS NULL restores them. + return squirrel.Or{base, squirrel.Eq{f.expr: nil}} +} + +// bareNullInclusion decides whether the index-friendly bare form is safe for this comparison and, +// if so, whether missing (NULL) rows must be re-included via `OR col IS NULL`. It returns ok=false +// when the bare form can't be proven equivalent to COALESCE(col, default) ? — i.e. the value +// isn't a scalar the comparator can order exactly (lists, bool ordering, unparseable values) — in +// which case the caller keeps the COALESCE form. When ok=true, wrapNull is true iff the default +// value itself satisfies the predicate (so missing rows would match and must be preserved). +func bareNullInclusion(defaultVal any, cmp comparator, value any) (wrapNull, ok bool) { + if b, isBool := defaultVal.(bool); isBool { + // Bool columns have an exact bare form only for equality; ordering operators fall back to + // COALESCE. Mapping both bools to 0/1 lets cmp.satisfy reuse the numeric eq/ne predicate. + if cmp.ordering { + return false, false + } + v, okV := criteria.ToBool(value) + if !okV { + return false, false + } + return cmp.satisfy(boolToFloat(b), boolToFloat(v)), true + } + d, okD := toFloat(defaultVal) + v, okV := toFloat(value) + if !okD || !okV { + // Non-scalar or unparseable value: no exact bare form, keep COALESCE. + return false, false + } + return cmp.satisfy(d, v), true +} + +func boolToFloat(b bool) float64 { + if b { + return 1 + } + return 0 +} + +// toFloat coerces a scalar criteria value to float64. Criteria values come from JSON (float64, +// string) or are built in Go (int, float64), so only those types are handled; anything else — +// including a slice or an unparseable string — reports ok=false so the caller keeps the COALESCE +// form instead of an index-friendly bare comparison. +func toFloat(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + case string: + f, err := strconv.ParseFloat(n, 64) + return f, err == nil + default: + return 0, false + } +} + +// sqlLiteral renders an annotation field's COALESCE default (0 or false) as a SQL literal for ORDER +// BY. %v renders both bool and numeric defaults correctly (false/true, 0). +func sqlLiteral(v any) string { + return fmt.Sprintf("%v", v) +} + func fieldJoinType(name string) smartPlaylistJoinType { info, ok := criteria.LookupField(name) if !ok { @@ -709,7 +839,9 @@ func sortExpr(sortField string) (string, bool) { if !ok || field.expr == "" { return "", false } - mapped = field.expr + // Sorting keeps the COALESCE default so missing-annotation rows sort as that default + // (filtering drops COALESCE for index use, but ORDER BY has no index to preserve here). + mapped = field.coalesced() } if info.Numeric { mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index d1a3931fc..a174192ea 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -30,16 +30,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { }, Entry("all group", criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}}, - "(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3), + "(media_file.title LIKE ? AND annotation.rating > ?)", "%love%", 3), Entry("any group", criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}}, "(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"), Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), - Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), + Entry("is bool", criteria.Is{"loved": true}, "annotation.starred = ?", true), Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), - Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), - Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), + Entry("gt", criteria.Gt{"playCount": 10}, "annotation.play_count > ?", 10), + Entry("lt", criteria.Lt{"playCount": 10}, "(annotation.play_count < ? OR annotation.play_count IS NULL)", 10), Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), @@ -50,8 +50,51 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("in playlist [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path = ? AND playlist.public = ?))", "lacuslacus.nsp", 1), Entry("in playlist [id]", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), - Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), + Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3), + Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), + // Annotation fields use a COALESCE default (0 for numeric, false for bool) so that tracks + // with no annotation row behave as that default. To keep the annotation index usable, the + // COALESCE is dropped when the compared value cannot match the default (the missing-row + // case is then naturally excluded); otherwise an explicit `OR col IS NULL` preserves it. + Entry("is safe (value != default)", criteria.Is{"playCount": 3}, "annotation.play_count = ?", 3), + Entry("is unsafe (value == default)", criteria.Is{"playCount": 0}, + "(annotation.play_count = ? OR annotation.play_count IS NULL)", 0), + Entry("is bool false (value == default)", criteria.Is{"loved": false}, + "(annotation.starred = ? OR annotation.starred IS NULL)", false), + Entry("gt safe (value >= default)", criteria.Gt{"playCount": 0}, "annotation.play_count > ?", 0), + Entry("gt unsafe (value < default)", criteria.Gt{"playCount": -1}, + "(annotation.play_count > ? OR annotation.play_count IS NULL)", -1), + Entry("lt safe (value <= default)", criteria.Lt{"playCount": 0}, "annotation.play_count < ?", 0), + Entry("lt unsafe (value > default)", criteria.Lt{"playCount": 5}, + "(annotation.play_count < ? OR annotation.play_count IS NULL)", 5), + Entry("isNot annotation keeps null match", criteria.IsNot{"playCount": 3}, + "(annotation.play_count <> ? OR annotation.play_count IS NULL)", 3), + Entry("isNot annotation value == default", criteria.IsNot{"playCount": 0}, + "annotation.play_count <> ?", 0), + Entry("in range spanning default", criteria.InTheRange{"playCount": []int{-1, 5}}, + "((annotation.play_count >= ? OR annotation.play_count IS NULL) AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", -1, 5), + Entry("in range above default", criteria.InTheRange{"playCount": []int{1, 5}}, + "(annotation.play_count >= ? AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", 1, 5), + // A list value can't drive the index and a default-inclusive list has per-element NULL + // semantics, so the COALESCE form is kept to stay equivalent to the original. + Entry("is list keeps coalesce", criteria.Is{"playCount": []int{0, 3}}, + "COALESCE(annotation.play_count, 0) IN (?,?)", 0, 3), + // LIKE operators can't use the column index, so annotation fields keep the COALESCE form to + // match missing-annotation rows exactly as before (a NULL column never matches LIKE). + Entry("contains annotation keeps coalesce", criteria.Contains{"playCount": 0}, + "COALESCE(annotation.play_count, 0) LIKE ?", "%0%"), + Entry("starts with annotation keeps coalesce", criteria.StartsWith{"rating": 5}, + "COALESCE(annotation.rating, 0) LIKE ?", "5%"), + Entry("not contains annotation keeps coalesce", criteria.NotContains{"playCount": 0}, + "COALESCE(annotation.play_count, 0) NOT LIKE ?", "%0%"), + // Bool annotation fields only have a clean index-friendly form for equality; ordering + // comparators keep the COALESCE form so the missing-row default is honored exactly. + Entry("gt bool keeps coalesce", criteria.Gt{"loved": false}, + "COALESCE(annotation.starred, false) > ?", false), + // A list value on a bool field is non-scalar, so it keeps the COALESCE form too (same as the + // numeric list case) — otherwise a NULL column would diverge from the original. + Entry("is bool list keeps coalesce", criteria.Is{"loved": []any{true}}, + "COALESCE(annotation.starred, false) IN (?)", true), Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), @@ -228,6 +271,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError(ContainSubstring("playlist id or path not given"))) }) + It("returns an error for a range over a tag/role field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).Where() + Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field"))) + }) + + It("returns a clear error for a malformed range value", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).Where() + Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index dd8145eae..d7d892ed1 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -117,15 +117,18 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { func mediaFileRecentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "media_file.updated_at" + return "media_file.updated_at, media_file.id" } - return "media_file.created_at" + return "media_file.created_at, media_file.id" } func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() - query = r.withAnnotation(query, "media_file.id") query = r.applyLibraryFilter(query) + // The annotation join is expensive with count(distinct) and pointless unless a filter uses it. + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "media_file.id") + } return r.count(query, options...) } @@ -306,7 +309,7 @@ func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, err return model.MediaFiles{}, nil } - sel := r.newSelect().Columns("*").Where(query) + sel := r.applyLibraryFilter(r.newSelect().Columns("*").Where(query)) var res dbMediaFiles if err := r.queryAll(sel, &res); err != nil { return nil, err diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 7989dc25a..80d440c41 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -45,6 +45,37 @@ var _ = Describe("MediaRepository", func() { Expect(mr.CountAll()).To(Equal(int64(13))) }) + Describe("CountAll annotation-join gating", func() { + var adminRepo model.MediaFileRepository + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) + adminRepo = NewMediaFileRepository(adminCtx, GetDBXBuilder()) + }) + + It("counts starred songs when an annotation filter is present", func() { + // Come Together (id 1002) is starred for the admin user in the seed data + count, err := adminRepo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "true"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + }) + + It("counts with starred=false without a 'no such column' error (join kept)", func() { + count, err := adminRepo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "false"), + }) + Expect(err).ToNot(HaveOccurred()) + // All songs except the one starred one + Expect(count).To(Equal(int64(12))) + }) + + It("counts unfiltered with the join dropped", func() { + Expect(adminRepo.CountAll()).To(Equal(int64(13))) + }) + }) + Describe("CountBySuffix", func() { var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile @@ -576,6 +607,34 @@ var _ = Describe("MediaRepository", func() { }) }) + It("breaks ties deterministically when files share the same created_at", func() { + conf.Server.RecentlyAddedByModTime = false + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + repo := NewMediaFileRepository(ctx, GetDBXBuilder()) + + ids := []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID} + sameTime := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) + _, err := GetDBXBuilder().Update("media_file", + dbx.Params{"created_at": sameTime}, + dbx.In("id", ids[0], ids[1], ids[2])).Execute() + Expect(err).ToNot(HaveOccurred()) + + order := func() []string { + res, err := repo.GetAll(model.QueryOptions{ + Sort: "recently_added", Order: "desc", + Filters: squirrel.Eq{"media_file.id": ids}}) + Expect(err).ToNot(HaveOccurred()) + out := make([]string, len(res)) + for i, mf := range res { + out[i] = mf.ID + } + return out + } + // Stable across repeated queries (no query-plan-dependent reordering). + Expect(order()).To(Equal(order())) + }) + }) }) @@ -880,6 +939,58 @@ var _ = Describe("MediaRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) + + Context("when the user has restricted library access", func() { + var otherLib model.Library + var restrictedUser model.User + + BeforeEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), adminUser) + lr := NewLibraryRepository(adminCtx, GetDBXBuilder()) + + // A second library the restricted user has no access to + otherLib = model.Library{ID: 0, Name: "Other Library", Path: "/other/lib"} + Expect(lr.Put(&otherLib)).To(Succeed()) + + // A track that lives only in the other library (created as admin) + adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(adminMr.Put(&model.MediaFile{ + ID: "otherlib-track", LibraryID: otherLib.ID, + Path: "hidden/test.mp3", Title: "Hidden", + })).To(Succeed()) + + // Non-admin user with access to library 1 ONLY + restrictedUser = createUserWithLibraries("restricted-finder", []int{1}) + ur := NewUserRepository(adminCtx, GetDBXBuilder()) + Expect(ur.Put(&restrictedUser)).To(Succeed()) + Expect(ur.SetUserLibraries(restrictedUser.ID, []int{1})).To(Succeed()) + }) + + AfterEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), adminUser) + _ = NewMediaFileRepository(adminCtx, GetDBXBuilder()).Delete("otherlib-track") + lr := NewLibraryRepository(adminCtx, GetDBXBuilder()).(*libraryRepository) + _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(adminCtx, GetDBXBuilder()).Delete(restrictedUser.ID) + }) + + It("does not resolve paths in libraries the user cannot access", func() { + userMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), restrictedUser), GetDBXBuilder()) + qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) + results, err := userMr.FindByPaths([]string{qualified}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty(), "a track outside the user's libraries must not be resolvable") + }) + + It("still resolves the path for an admin", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) + results, err := adminMr.FindByPaths([]string{qualified}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("otherlib-track")) + }) + }) }) Describe("wrapMediaFileCursor", func() { diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 89dc19e19..0013e782b 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -100,16 +100,28 @@ func (r *shareRepository) loadMedia(share *model.Share) error { share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) return err case "playlist": - // Create a context with a fake admin user, to be able to access all playlists - ctx := request.WithUser(r.ctx, model.User{IsAdmin: true}) + // Load tracks as the share owner so their library access is applied. + owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID) + if err != nil { + return fmt.Errorf("loading share owner %q: %w", share.UserID, err) + } + if owner == nil { + return fmt.Errorf("share owner %q not found", share.UserID) + } + ctx := request.WithUser(r.ctx, *owner) plsRepo := NewPlaylistRepository(ctx, r.db) - tracks, err := plsRepo.Tracks(ids[0], true).GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) + // Tracks returns nil when the playlist is no longer visible to the owner + // (e.g. it was made private after the share was created); leave the share + // with no tracks rather than exposing it. + trackRepo := plsRepo.Tracks(ids[0], true) + if trackRepo == nil { + return nil + } + tracks, err := trackRepo.GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) if err != nil { return err } - if len(tracks) >= 0 { - share.Tracks = tracks.MediaFiles() - } + share.Tracks = tracks.MediaFiles() return nil case "media_file": mfRepo := NewMediaFileRepository(r.ctx, r.db) diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 0b3ece598..3ae456031 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" @@ -132,6 +133,101 @@ var _ = Describe("ShareRepository", func() { }) }) + Describe("Playlist share library scoping", func() { + var otherLib model.Library + var owner model.User + var plsID string + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + + // A second library the owner has no access to, plus a track in it + lr := NewLibraryRepository(adminCtx, GetDBXBuilder()) + otherLib = model.Library{ID: 0, Name: "Share Other Library", Path: "/share/other/lib"} + Expect(lr.Put(&otherLib)).To(Succeed()) + mr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(mr.Put(&model.MediaFile{ID: "share-other", LibraryID: otherLib.ID, Path: "s/other.mp3", Title: "ShareOther"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "share-ok", LibraryID: 1, Path: "s/ok.mp3", Title: "ShareOK"})).To(Succeed()) + + // Non-admin owner with access to library 1 only + owner = createUserWithLibraries("share-owner", []int{1}) + ur := NewUserRepository(adminCtx, GetDBXBuilder()) + Expect(ur.Put(&owner)).To(Succeed()) + Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed()) + + // Owner-owned playlist containing tracks from both libraries + plsID = "share-scope-pls" + ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), owner) + pr := NewPlaylistRepository(ownerCtx, GetDBXBuilder()) + pls := &model.Playlist{ID: plsID, Name: "Scope Test", OwnerID: owner.ID} + pls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}, {ID: "share-other"}}) + Expect(pr.Put(pls)).To(Succeed()) + + // Share row owned by the non-admin owner + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": "share-scope", "user": owner.ID, "desc": "Scope test share", + "type": "playlist", "ids": plsID, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + b := GetDBXBuilder() + _, _ = b.NewQuery(`DELETE FROM share WHERE id = 'share-scope'`).Execute() + pr := NewPlaylistRepository(adminCtx, b) + _ = pr.Delete(plsID) + mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository) + _, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"share-other", "share-ok"}})) + lr := NewLibraryRepository(adminCtx, b).(*libraryRepository) + _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(adminCtx, b).Delete(owner.ID) + }) + + It("excludes tracks the owner cannot access from the shared playlist", func() { + // Read the share as admin (mimics the public-share render path, which uses + // the share repository's own context). loadMedia must scope to the owner. + adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) + share, err := adminRepo.Get("share-scope") + Expect(err).ToNot(HaveOccurred()) + + Expect(share.Tracks).To(ContainElement(HaveField("ID", "share-ok"))) + Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "share-other")), + "a track outside the owner's libraries must not appear in the share") + }) + + It("returns no tracks when the playlist is not visible to the owner", func() { + // A private playlist owned by someone else: the share owner can no longer + // see it, so Tracks() returns nil. The share must render with no tracks + // instead of panicking. + privatePlsID := "private-pls" + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + pr := NewPlaylistRepository(adminCtx, GetDBXBuilder()) + privatePls := &model.Playlist{ID: privatePlsID, Name: "Private", OwnerID: adminUser.ID, Public: false} + privatePls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}}) + Expect(pr.Put(privatePls)).To(Succeed()) + DeferCleanup(func() { _ = pr.Delete(privatePlsID) }) + + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": "share-private", "user": owner.ID, "desc": "Private share", + "type": "playlist", "ids": privatePlsID, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery(`DELETE FROM share WHERE id = 'share-private'`).Execute() }) + + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + share, err := adminRepo.Get("share-private") + Expect(err).ToNot(HaveOccurred()) + Expect(share.Tracks).To(BeEmpty()) + }) + }) + Describe("Ownership Checks", func() { var ownerUser = model.User{ID: "2222", UserName: "regular-user"} var otherUser = model.User{ID: "3333", UserName: "third-user"} diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 07bd96975..78b7938a1 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -4,17 +4,61 @@ import ( "database/sql" "errors" "fmt" + "regexp" + "sort" "strings" + "sync" "time" . "github.com/Masterminds/squirrel" + "github.com/fatih/structs" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" ) const annotationTable = "annotation" +// annotationColumns are the columns withAnnotation's LEFT JOIN contributes, derived from +// model.Annotations so the set tracks schema changes. average_rating is excluded: it lives on the +// base table, not the annotation join. +var annotationColumns = sync.OnceValue(func() map[string]struct{} { + cols := map[string]struct{}{} + for name := range structs.Map(model.Annotations{}) { + if name == "average_rating" { + continue + } + cols[name] = struct{}{} + } + return cols +}) + +// annotationColumnRE matches any annotation column as a whole word. The word boundaries keep the +// base-table column average_rating from matching the annotation column rating (Go's \b treats '_' +// as a word char). It is case-insensitive because SQLite column names are, so a raw filter using +// e.g. "RATING" must still be detected. +var annotationColumnRE = sync.OnceValue(func() *regexp.Regexp { + cols := make([]string, 0, len(annotationColumns())) + for col := range annotationColumns() { + cols = append(cols, regexp.QuoteMeta(col)) + } + sort.Strings(cols) // map iteration is random; sort for a stable pattern + return regexp.MustCompile(`(?i)\b(?:` + strings.Join(cols, "|") + `)\b`) +}) + +// filtersNeedAnnotation reports whether the rendered query references an annotation column, i.e. +// whether the annotation LEFT JOIN must be kept. Scanning the rendered SQL catches every filter +// path. The placeholder column is needed because squirrel won't render a column-less SELECT; on a +// render error, keep the join to be safe. +func filtersNeedAnnotation(query SelectBuilder) bool { + sql, _, err := query.Columns("1").ToSql() + if err != nil { + return true + } + return annotationColumnRE().MatchString(sql) +} + func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder { userID := loggedUser(r.ctx).ID if userID == invalidUserId { diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index 15efc5dc7..5766f687f 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -150,4 +150,98 @@ var _ = Describe("Annotation Filters", func() { } Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored") }) + + Describe("annotationColumns", func() { + It("derives the annotation join columns from model.Annotations, excluding average_rating", func() { + cols := annotationColumns() + Expect(cols).To(HaveKey("starred")) + Expect(cols).To(HaveKey("starred_at")) + Expect(cols).To(HaveKey("rating")) + Expect(cols).To(HaveKey("rated_at")) + Expect(cols).To(HaveKey("play_count")) + Expect(cols).To(HaveKey("play_date")) + Expect(cols).To(HaveLen(6), "expected exactly the 6 annotation-join columns") + Expect(cols).ToNot(HaveKey("average_rating"), "average_rating lives on the base table, not the annotation join") + }) + }) + + Describe("filtersNeedAnnotation", func() { + It("is true when the query references an annotation column", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"starred": true}) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is true for a raw expression referencing an annotation column", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("rating > 0")) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is false for a query that references no annotation column", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"missing": false}) + Expect(filtersNeedAnnotation(q)).To(BeFalse()) + }) + + It("is false for a filter on average_rating (base-table column, not the annotation rating)", func() { + // Regression: average_rating must not match the annotation column "rating". + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Gt{"average_rating": 3}) + Expect(filtersNeedAnnotation(q)).To(BeFalse()) + }) + + It("is true when both average_rating and a real annotation column are referenced", func() { + q := squirrel.Select("count(1)").From("media_file"). + Where(squirrel.Gt{"average_rating": 3}). + Where(squirrel.Expr("COALESCE(rating, 0) > 0")) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is true for uppercase/mixed-case annotation columns (SQLite is case-insensitive)", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("RATING > 0")) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is false for uppercase average_rating (still excluded case-insensitively)", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("AVERAGE_RATING > 3")) + Expect(filtersNeedAnnotation(q)).To(BeFalse()) + }) + }) + + Describe("CountAll annotation-join gating", func() { + It("counts all items unfiltered (join dropped)", func() { + total, err := albumRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(BeNumerically(">=", int64(1))) + + filtered, err := albumRepo.CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.id": albumWithoutAnnotation.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(filtered).To(Equal(int64(1))) + }) + + It("counts starred items correctly (named annotation filter keeps the join)", func() { + starredAlbum := model.Album{ID: "counted-starred-album", Name: "Counted Starred", LibraryID: 1} + Expect(albumRepo.Put(&starredAlbum)).To(Succeed()) + Expect(albumRepo.SetStar(true, starredAlbum.ID)).To(Succeed()) + defer func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": starredAlbum.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": starredAlbum.ID})) + }() + + // Exactly two albums are starred for this user: the one created above and + // albumRadioactivity (id 103) from the seed data. + count, err := albumRepo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "true"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(2))) + }) + + It("counts via a raw annotation filter without a 'no such column' error", func() { + count, err := albumRepo.CountAll(model.QueryOptions{ + Filters: squirrel.Expr("COALESCE(rating, 0) > 0"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically(">=", int64(0))) + }) + }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 321e790db..ce5221d19 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -229,6 +229,12 @@ func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) return sq } + // A non-admin granted every library sees everything the subquery would return, so applying it is + // pure overhead. Skip it in that case (same fast path admins get). + if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) { + return sq + } + table := r.tableName if len(tableName) > 0 { table = tableName[0] @@ -240,6 +246,32 @@ func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) "SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", user.ID)) } +// userSeesAllLibraries reports whether the visible set already covers every library, so a +// library filter would exclude nothing. +func (r sqlRepository) userSeesAllLibraries(visible []int) bool { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + return true // visible is the whole library table + } + total, err := NewLibraryRepository(r.ctx, r.db).CountAll() + if err != nil || total == 0 { + return false + } + return int64(len(visible)) == total +} + +// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and +// headless processes, otherwise the user's granted libraries. +func (r sqlRepository) visibleLibraryIDs() ([]int, error) { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + var ids []int + err := r.queryAllSlice(Select("id").From("library"), &ids) + return ids, err + } + return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil +} + func (r sqlRepository) seedKey() string { // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed // used in the query. Hashing the user ID and converting it to a hex string will do the trick diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index b46e2066b..9c6c6007f 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -226,9 +226,21 @@ var _ = Describe("sqlRepository", func() { Describe("applyLibraryFilter", func() { var sq squirrel.SelectBuilder + var savedDB = r.db BeforeEach(func() { sq = squirrel.Select("*").From("test_table") + // Add library 2 so a user granted only library 1 is a genuine strict subset. + savedDB = r.db + r.db = GetDBXBuilder() + _, err := r.db.NewQuery("INSERT OR IGNORE INTO library (id, name, path) VALUES (2, 'Lib 2', '/lib2')").Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + _, err := r.db.NewQuery("DELETE FROM library WHERE id = 2").Execute() + Expect(err).ToNot(HaveOccurred()) + r.db = savedDB }) Context("Admin User", func() { @@ -238,31 +250,82 @@ var _ = Describe("sqlRepository", func() { It("should not apply library filter for admin users", func() { result := r.applyLibraryFilter(sq) - sql, _, _ := result.ToSql() + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) }) - Context("Regular User", func() { + Context("Regular User with a subset of libraries", func() { BeforeEach(func() { - r.ctx = request.WithUser(context.Background(), model.User{ID: "user123", IsAdmin: false}) + // Strict subset: granted lib 1, DB has libs 1 and 2, so the filter must apply. + r.ctx = request.WithUser(context.Background(), model.User{ + ID: "user123", IsAdmin: false, Libraries: model.Libraries{{ID: 1}}, + }) }) It("should apply library filter for regular users", func() { result := r.applyLibraryFilter(sq) - sql, args, _ := result.ToSql() + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) Expect(args).To(ContainElement("user123")) }) It("should use custom table name when provided", func() { result := r.applyLibraryFilter(sq, "custom_table") - sql, args, _ := result.ToSql() + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("custom_table.library_id IN")) Expect(args).To(ContainElement("user123")) }) }) + Context("Regular User with no libraries", func() { + BeforeEach(func() { + r.ctx = request.WithUser(context.Background(), model.User{ID: "empty", IsAdmin: false}) + }) + + It("should apply the library filter (never skip on empty)", func() { + result := r.applyLibraryFilter(sq) + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) + }) + }) + + Context("Regular User who can see all libraries", func() { + BeforeEach(func() { + // Grant every library that currently exists in the (shared) DB, so the filter + // would exclude nothing. Querying the real IDs keeps this correct even if other + // specs left extra libraries behind, which happens under Ginkgo's randomized order. + var ids []int + err := r.db.NewQuery("SELECT id FROM library ORDER BY id").Column(&ids) + Expect(err).ToNot(HaveOccurred()) + libs := make(model.Libraries, 0, len(ids)) + for _, id := range ids { + libs = append(libs, model.Library{ID: id}) + } + r.ctx = request.WithUser(context.Background(), model.User{ + ID: "alllibs", IsAdmin: false, Libraries: libs, + }) + }) + + It("should not apply the library filter (subquery would filter nothing)", func() { + result := r.applyLibraryFilter(sq) + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + + It("should not apply the filter even with a custom table name", func() { + result := r.applyLibraryFilter(sq, "custom_table") + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + }) + Context("Headless Process (No User Context)", func() { BeforeEach(func() { r.ctx = context.Background() // No user context @@ -270,13 +333,15 @@ var _ = Describe("sqlRepository", func() { It("should not apply library filter for headless processes", func() { result := r.applyLibraryFilter(sq) - sql, _, _ := result.ToSql() + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) It("should not apply library filter even with custom table name", func() { result := r.applyLibraryFilter(sq, "custom_table") - sql, _, _ := result.ToSql() + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) }) diff --git a/plugins/README.md b/plugins/README.md index 048cf549d..b9118d36f 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1030,8 +1030,6 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` -**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations). - ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: diff --git a/plugins/capabilities/README.md b/plugins/capabilities/README.md index fca3cbd31..2ad4a82da 100644 --- a/plugins/capabilities/README.md +++ b/plugins/capabilities/README.md @@ -76,7 +76,7 @@ The YAML schemas in this package are automatically generated from the capability To regenerate the schemas after modifying the interfaces, run: ```bash -cd plugins/cmd/ndpgen && go run . -schemas -input=./plugins/capabilities +cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types ``` ## Resources diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 04dd283dd..7336124fc 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -9,20 +9,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name GetLyricsRequest: description: GetLyricsRequest contains the track information for lyrics lookup. properties: @@ -124,3 +110,31 @@ components: - duration - trackNumber - discNumber + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). + required: + - name diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index 407f21ec5..f856562c6 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // MetadataAgent provides artist and album metadata retrieval. // This capability allows plugins to provide external metadata for artists and albums, // such as biographies, images, similar artists, and top songs. @@ -102,10 +104,13 @@ type SimilarArtistsRequest struct { Limit int32 `json:"limit"` } +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // ImageInfo represents an image with URL and size. @@ -134,32 +139,13 @@ type TopSongsRequest struct { Count int32 `json:"count"` } -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` -} +// Deprecated: use types.SongRef. +type SongRef = types.SongRef // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // AlbumRequest is the common request for album-related functions. @@ -233,5 +219,5 @@ type SimilarSongsByArtistRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index 4940a5056..6e528c7e7 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -173,20 +173,6 @@ components: description: MBID is the MusicBrainz ID for the artist. required: - mbid - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name ArtistRequest: description: ArtistRequest is the common request for artist-related functions. properties: @@ -331,39 +317,6 @@ components: $ref: '#/components/schemas/SongRef' required: - songs - SongRef: - description: SongRef is a reference to a song with metadata for matching. - properties: - id: - type: string - description: ID is the internal Navidrome mediafile ID (if known). - name: - type: string - description: Name is the song name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the song. - isrc: - type: string - description: ISRC is the International Standard Recording Code for the song. - artist: - type: string - description: Artist is the artist name. - artistMbid: - type: string - description: ArtistMBID is the MusicBrainz artist ID. - album: - type: string - description: Album is the album name. - albumMbid: - type: string - description: AlbumMBID is the MusicBrainz release ID. - duration: - type: number - format: float - description: Duration is the song duration in seconds. - required: - - name TopSongsRequest: description: TopSongsRequest is the request for GetArtistTopSongs. properties: @@ -394,3 +347,91 @@ components: $ref: '#/components/schemas/SongRef' required: - songs + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). + required: + - name + SongRef: + description: |- + SongRef is the minimal information exchanged between a plugin and Navidrome to + match a song. It is used both as input (a song Navidrome already has) and as + output (a song a plugin suggests, which may not be in the library yet). Unlike + Track, it is an abstract recording reference carrying only matching keys (IDs, + ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID (if known). + name: + type: string + description: Name is the song name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: |- + Artist is the artist name. + + Deprecated: use Artists. + artistMbid: + type: string + description: |- + ArtistMBID is the MusicBrainz artist ID. + + Deprecated: use Artists. + artists: + type: array + description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + items: + $ref: '#/components/schemas/ArtistRef' + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: |- + Duration is the song duration in seconds. + + Deprecated: use DurationMs, which carries millisecond precision. When + DurationMs is non-zero it takes precedence; Duration is kept only for + backwards compatibility with plugins that still send seconds. + durationMs: + type: integer + format: int64 + description: |- + DurationMs is the song duration in milliseconds. It supersedes Duration + when non-zero. + required: + - name diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 4918d5e8f..c1c05839a 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // Scrobbler provides scrobbling functionality to external services. // This capability allows plugins to submit listening history to services like Last.fm, // ListenBrainz, or custom scrobbling backends. @@ -32,16 +34,6 @@ type IsAuthorizedRequest struct { Username string `json:"username"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. @@ -55,9 +47,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 9d5cfed30..2b862b964 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -25,20 +25,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name IsAuthorizedRequest: description: IsAuthorizedRequest is the request for authorization check. properties: @@ -194,3 +180,31 @@ components: - duration - trackNumber - discNumber + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). + required: + - name diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go index aadb9396e..a35d9d923 100644 --- a/plugins/capabilities/sonic_similarity.go +++ b/plugins/capabilities/sonic_similarity.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // SonicSimilarity provides audio-similarity based track discovery. // //nd:capability name=sonicsimilarity required=true @@ -12,14 +14,14 @@ type SonicSimilarity interface { } type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } type SonicSimilarityResponse struct { @@ -27,6 +29,6 @@ type SonicSimilarityResponse struct { } type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml index cba97d9b0..a336fbce8 100644 --- a/plugins/capabilities/sonic_similarity.yaml +++ b/plugins/capabilities/sonic_similarity.yaml @@ -39,39 +39,6 @@ components: required: - song - count - SongRef: - description: SongRef is a reference to a song with metadata for matching. - properties: - id: - type: string - description: ID is the internal Navidrome mediafile ID (if known). - name: - type: string - description: Name is the song name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the song. - isrc: - type: string - description: ISRC is the International Standard Recording Code for the song. - artist: - type: string - description: Artist is the artist name. - artistMbid: - type: string - description: ArtistMBID is the MusicBrainz artist ID. - album: - type: string - description: Album is the album name. - albumMbid: - type: string - description: AlbumMBID is the MusicBrainz release ID. - duration: - type: number - format: float - description: Duration is the song duration in seconds. - required: - - name SonicMatch: properties: song: @@ -90,3 +57,91 @@ components: $ref: '#/components/schemas/SonicMatch' required: - matches + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). + required: + - name + SongRef: + description: |- + SongRef is the minimal information exchanged between a plugin and Navidrome to + match a song. It is used both as input (a song Navidrome already has) and as + output (a song a plugin suggests, which may not be in the library yet). Unlike + Track, it is an abstract recording reference carrying only matching keys (IDs, + ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID (if known). + name: + type: string + description: Name is the song name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: |- + Artist is the artist name. + + Deprecated: use Artists. + artistMbid: + type: string + description: |- + ArtistMBID is the MusicBrainz artist ID. + + Deprecated: use Artists. + artists: + type: array + description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + items: + $ref: '#/components/schemas/ArtistRef' + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: |- + Duration is the song duration in seconds. + + Deprecated: use DurationMs, which carries millisecond precision. When + DurationMs is non-zero it takes precedence; Duration is kept only for + backwards compatibility with plugins that still send seconds. + durationMs: + type: integer + format: int64 + description: |- + DurationMs is the song duration in milliseconds. It supersedes Duration + when non-zero. + required: + - name diff --git a/plugins/cmd/ndpgen/README.md b/plugins/cmd/ndpgen/README.md index d2f67a60c..7487db892 100644 --- a/plugins/cmd/ndpgen/README.md +++ b/plugins/cmd/ndpgen/README.md @@ -7,7 +7,7 @@ This tool is the unified code generator that handle both host function wrappers ## Usage ```bash -ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust] +ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-rust] ``` ### Flags @@ -21,10 +21,9 @@ ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] | `-dry-run` | Parse and validate without writing files | `false` | | `-host-only` | Generate only host function wrappers (capability support TBD) | `true` | | `-go` | Generate Go client wrappers | `true`* | -| `-python` | Generate Python client wrappers | `false` | | `-rust` | Generate Rust client wrappers | `false` | -\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages. +\* `-go` is enabled by default when `-rust` is not specified. Use `-go -rust` to generate both languages. ### Example @@ -150,10 +149,6 @@ func TestMyPluginFunction(t *testing.T) { If you need to reset mock state between tests, testify's mock doesn't have a built-in reset. Either use separate test functions (testify automatically resets between test runs), or create a helper to set up fresh expectations. -### Python Client Library - -When using `-python`, Python client files are generated in a `python/` subdirectory. - ### Rust Client Library When using `-rust`, Rust client files are generated in a `rust/` subdirectory. diff --git a/plugins/cmd/ndpgen/go.mod b/plugins/cmd/ndpgen/go.mod index af9fce441..6b081a826 100644 --- a/plugins/cmd/ndpgen/go.mod +++ b/plugins/cmd/ndpgen/go.mod @@ -1,13 +1,13 @@ module github.com/navidrome/navidrome/plugins/cmd/ndpgen -go 1.25 +go 1.25.0 require ( github.com/extism/go-pdk v1.1.3 github.com/onsi/ginkgo/v2 v2.27.5 github.com/onsi/gomega v1.39.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - golang.org/x/tools v0.41.0 + golang.org/x/tools v0.44.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -18,9 +18,9 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/plugins/cmd/ndpgen/go.sum b/plugins/cmd/ndpgen/go.sum index 952672d0e..828273cab 100644 --- a/plugins/cmd/ndpgen/go.sum +++ b/plugins/cmd/ndpgen/go.sum @@ -54,18 +54,18 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index db500c1fc..d8bc5859d 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -176,16 +176,15 @@ type ServiceB interface { Describe("code generation", func() { DescribeTable("generates correct client output", - func(serviceFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) { + func(serviceFile, goClientExpectedFile, rsClientExpectedFile string) { serviceCode := readTestdata(serviceFile) goClientExpected := readTestdata(goClientExpectedFile) - pyClientExpected := readTestdata(pyClientExpectedFile) rsClientExpected := readTestdata(rsClientExpectedFile) Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - // Generate all client code (Go, Python, Rust) - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python", "-rust") + // Generate all client code (Go, Rust) + cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-rust") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) @@ -217,17 +216,6 @@ type ServiceB interface { Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch") - // Verify Python client code (now in $output/python/host/) - pythonHostDir := filepath.Join(outputDir, "python", "host") - pyClientEntries, err := os.ReadDir(pythonHostDir) - Expect(err).ToNot(HaveOccurred()) - Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file") - - pyClientActual, err := os.ReadFile(filepath.Join(pythonHostDir, pyClientEntries[0].Name())) - Expect(err).ToNot(HaveOccurred()) - - Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch") - // Verify Rust client code (now in $output/rust/nd-pdk-host/src/) rustSrcDir := filepath.Join(outputDir, "rust", "nd-pdk-host", "src") rsClientEntries, err := os.ReadDir(rustSrcDir) @@ -251,39 +239,59 @@ type ServiceB interface { }, Entry("simple string params", - "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.py", "echo_client_expected.rs"), + "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.rs"), Entry("multiple simple params (int32)", - "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.py", "math_client_expected.rs"), + "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.rs"), Entry("struct param with request type", - "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.py", "store_client_expected.rs"), + "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.rs"), Entry("mixed simple and complex params", - "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.py", "list_client_expected.rs"), + "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.rs"), Entry("method without error", - "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.py", "counter_client_expected.rs"), + "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.rs"), Entry("no params, error only", - "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.py", "ping_client_expected.rs"), + "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.rs"), Entry("map and interface types", - "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.py", "meta_client_expected.rs"), + "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.rs"), Entry("pointer types", - "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.py", "users_client_expected.rs"), + "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.rs"), Entry("multiple returns", - "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.py", "search_client_expected.rs"), + "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.rs"), Entry("bytes", - "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.py", "codec_client_expected.rs"), + "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.rs"), Entry("option pattern (value, exists bool)", - "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"), + "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.rs"), ) + It("generates the shared Go types package with -shared-types", func() { + typesSrc := `package types + +// ArtistRef references an artist. +type ArtistRef struct { + ID string ` + "`json:\"id,omitempty\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} +` + Expect(os.WriteFile(filepath.Join(testDir, "types.go"), []byte(typesSrc), 0600)).To(Succeed()) + cmd := exec.Command(ndpgenBin, "-shared-types", "-input", testDir, "-output", outputDir, "-go") + out, err := cmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "Command failed: %s", out) + + content, err := os.ReadFile(filepath.Join(outputDir, "go", "types", "types.go")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("package types")) + Expect(string(content)).To(ContainSubstring("type ArtistRef struct {")) + }) + It("generates compilable client code for comprehensive service", func() { serviceCode := readTestdata("comprehensive_service.go.txt") @@ -386,119 +394,6 @@ var _ = ndpdk.ComprehensiveNoParams Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile()) }) - It("generates Python client code with -python flag", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - DoAction(ctx context.Context, input string) (output string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Verify Python client code exists in $output/python/host/ - pythonHostDir := filepath.Join(outputDir, "python", "host") - Expect(pythonHostDir).To(BeADirectory()) - - pythonFile := filepath.Join(pythonHostDir, "nd_host_test.py") - Expect(pythonFile).To(BeAnExistingFile()) - - content, err := os.ReadFile(pythonFile) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):")) - Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`)) - Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:")) - }) - - It("generates both Go and Python client code with -go -python flags", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - DoAction(ctx context.Context, input string) (output string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Verify Go client code exists in $output/go/host/ - goHostDir := filepath.Join(outputDir, "go", "host") - Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile()) - - // Verify Python client code exists in $output/python/host/ - pythonHostDir := filepath.Join(outputDir, "python", "host") - Expect(pythonHostDir).To(BeADirectory()) - Expect(filepath.Join(pythonHostDir, "nd_host_test.py")).To(BeAnExistingFile()) - }) - - It("generates Python code with dataclass for multi-value returns", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Cache permission=cache -type CacheService interface { - //nd:hostfunc - GetString(ctx context.Context, key string) (value string, exists bool, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_cache.py")) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("@dataclass")) - Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:")) - Expect(contentStr).To(ContainSubstring("value: str")) - Expect(contentStr).To(ContainSubstring("exists: bool")) - Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:")) - }) - - It("generates Python code for methods with no parameters", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - Ping(ctx context.Context) (status string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_test.py")) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("def test_ping() -> str:")) - Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`)) - }) }) }) diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 705cd4d36..50f53cd37 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -4,6 +4,7 @@ import ( "bytes" "embed" "fmt" + "slices" "strings" "text/template" ) @@ -79,17 +80,6 @@ func mockAccessor(typ string, idx int) string { } } -// pythonFuncMap returns the template functions for Python client code generation. -func pythonFuncMap(svc Service) template.FuncMap { - return template.FuncMap{ - "lower": strings.ToLower, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) }, - "pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) }, - "pythonDefault": pythonDefaultValue, - } -} - // GenerateHost generates the host function wrapper code for a service. func GenerateHost(svc Service, pkgName string) ([]byte, error) { tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl") @@ -186,51 +176,13 @@ func formatDoc(doc string) string { return strings.Join(result, "\n") } -// GenerateClientPython generates Python client wrapper code for plugins. -func GenerateClientPython(svc Service) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/client.py.tmpl") - if err != nil { - return nil, fmt.Errorf("reading Python client template: %w", err) - } - - tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := templateData{ - Service: svc, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// pythonDefaultValue returns a Python default value for response.get() calls. -func pythonDefaultValue(p Param) string { - switch p.Type { - case "string": - return `, ""` - case "int", "int32", "int64": - return ", 0" - case "float32", "float64": - return ", 0.0" - case "bool": - return ", False" - case "[]byte": - return ", b\"\"" - default: - return ", None" - } -} - // rustFuncMap returns the template functions for Rust client code generation. func rustFuncMap(svc Service) template.FuncMap { knownStructs := svc.KnownStructs() + shared := make(map[string]string) + for _, a := range svc.SharedAliases { + shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) + } return template.FuncMap{ "lower": strings.ToLower, "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, @@ -238,9 +190,9 @@ func rustFuncMap(svc Service) template.FuncMap { "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) }, "rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) }, "rustDocComment": RustDocComment, - "rustType": func(p Param) string { return p.RustTypeWithStructs(knownStructs) }, - "rustParamType": func(p Param) string { return p.RustParamTypeWithStructs(knownStructs) }, - "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, + "rustType": func(p Param) string { return p.RustTypeWithShared(knownStructs, shared) }, + "rustParamType": func(p Param) string { return p.RustParamTypeWithShared(knownStructs, shared) }, + "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, } } @@ -388,6 +340,18 @@ func indentText(n int, s string) string { return strings.Join(lines, "\n") } +// indentSpaces adds n spaces to each non-empty line of text. +func indentSpaces(spaces int, s string) string { + ind := strings.Repeat(" ", spaces) + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = ind + line + } + } + return strings.Join(lines, "\n") +} + // capabilityAgentName returns the interface name for a capability. // Uses the Go interface name stripped of common suffixes. func capabilityAgentName(cap Capability) string { @@ -459,6 +423,10 @@ func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) { // rustCapabilityFuncMap returns template functions for Rust capability code generation. func rustCapabilityFuncMap(cap Capability) template.FuncMap { knownStructs := cap.KnownStructs() + shared := make(map[string]string) + for _, a := range cap.SharedAliases { + shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) + } return template.FuncMap{ "rustDocComment": RustDocComment, "rustTypeAlias": rustTypeAlias, @@ -466,25 +434,25 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap { "rustConstName": rustConstName, "rustFieldName": func(name string) string { return ToSnakeCase(name) }, "rustMethodName": func(name string) string { return ToSnakeCase(name) }, - "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, - "rustOutputType": rustOutputType, - "isPrimitiveRust": isPrimitiveRustType, + "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, + "rustOutputType": func(goType string) string { return rustTraitType(goType, shared) }, + "rustMethodType": func(goType string) string { return rustMethodType(goType, cap.Name, shared) }, "skipSerializingFunc": skipSerializingFunc, "hasHashMap": hasHashMap, "agentName": capabilityAgentName, "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, "registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) }, - "snakeCase": ToSnakeCase, - "indent": func(spaces int, s string) string { - indent := strings.Repeat(" ", spaces) - lines := strings.Split(s, "\n") - for i, line := range lines { - if line != "" { - lines[i] = indent + line - } - } - return strings.Join(lines, "\n") + "rustSharedTarget": func(target string) string { + return "nd_pdk_types::" + strings.TrimPrefix(target, sharedTypesPrefix) }, + // rustSharedNote is the human-facing path for deprecation notes: plugin + // authors depend on the nd-pdk umbrella crate, which re-exports nd_pdk_types + // as `types`, so they reference these via nd_pdk::types::X. + "rustSharedNote": func(target string) string { + return "nd_pdk::types::" + strings.TrimPrefix(target, sharedTypesPrefix) + }, + "snakeCase": ToSnakeCase, + "indent": indentSpaces, } } @@ -526,6 +494,46 @@ func rustConstType(goType string) string { // TODO: Pointer to primitive types (e.g., *string, *int32) are not handled correctly. // Currently "*string" returns "string" instead of "String". This would generate invalid // Rust code. No current capability uses this pattern, but it should be fixed if needed. +// rustMethodType returns the fully-qualified Rust type for a capability method +// input/output as referenced inside the generated export macro. The macro expands +// in the downstream plugin crate, which depends on the umbrella nd-pdk crate and +// not on nd-pdk-types directly, so shared types must be reached through $crate +// (the defining nd-pdk-capabilities crate, which re-exports nd_pdk_types as +// `types`) rather than by naming the transitive crate. Primitives map to their +// Rust name; any other named type is a capability-local struct, qualified as +// $crate::::X. This is used instead of hand-assembling +// "$crate::::" + rustOutputType, which produced invalid paths like +// "$crate::demo::types.SongRef" for shared types used directly in a signature. +func rustMethodType(goType, pkg string, shared map[string]string) string { + goType = strings.TrimPrefix(goType, "*") + if isPrimitiveRustType(goType) { + return rustOutputType(goType) + } + if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { + return "$crate::types::" + rest + } + if t, ok := shared[goType]; ok { + return "$crate::types::" + strings.TrimPrefix(t, "nd_pdk_types::") + } + return "$crate::" + ToSnakeCase(pkg) + "::" + goType +} + +// rustTraitType returns the Rust type for a capability trait method signature. +// The trait lives in the capability module alongside its local structs, so those +// stay bare; shared types must still resolve to their nd_pdk_types::X crate path +// (a shared type used directly in a signature would otherwise pass through as the +// invalid Go selector "types.SongRef"). +func rustTraitType(goType string, shared map[string]string) string { + stripped := strings.TrimPrefix(goType, "*") + if rest, ok := strings.CutPrefix(stripped, sharedTypesPrefix); ok { + return "nd_pdk_types::" + rest + } + if t, ok := shared[stripped]; ok { + return t + } + return rustOutputType(goType) +} + func rustOutputType(goType string) string { // Strip pointer prefix - capability outputs use Result for optionality if strings.HasPrefix(goType, "*") { @@ -568,9 +576,16 @@ func rustConstName(name string) string { } // skipSerializingFunc returns the appropriate skip_serializing_if function name. +// The check must match the rendered Rust type: pointers become Option, slices Vec, +// and maps HashMap, each with a different emptiness predicate. func skipSerializingFunc(goType string) string { - if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") { + switch { + case strings.HasPrefix(goType, "*"): return "Option::is_none" + case strings.HasPrefix(goType, "[]"): + return "Vec::is_empty" + case strings.HasPrefix(goType, "map["): + return "HashMap::is_empty" } switch goType { case "string": @@ -594,9 +609,9 @@ func skipSerializingFunc(goType string) string { } } -// hasHashMap returns true if any struct in the capability uses HashMap. -func hasHashMap(cap Capability) bool { - for _, st := range cap.Structs { +// anyFieldUsesHashMap returns true if any field in the given structs uses a map type. +func anyFieldUsesHashMap(structs []StructDef) bool { + for _, st := range structs { for _, f := range st.Fields { if strings.HasPrefix(f.Type, "map[") { return true @@ -606,6 +621,32 @@ func hasHashMap(cap Capability) bool { return false } +// anyFieldIsByteSlice reports whether any field across the given structs is a +// []byte, which Go's JSON encoder serializes as a base64 string. The Rust +// shared-types crate must match that with a base64_bytes serde override. +func anyFieldIsByteSlice(structs []StructDef) bool { + for _, st := range structs { + for _, f := range st.Fields { + if f.IsByteSlice() { + return true + } + } + } + return false +} + +// hasHashMap returns true if any struct in the capability uses HashMap. +func hasHashMap(cap Capability) bool { + return anyFieldUsesHashMap(cap.Structs) +} + +// sortedStructs returns a sorted copy of structs, ordered by name. +func sortedStructs(structs []StructDef) []StructDef { + sorted := append([]StructDef(nil), structs...) + slices.SortFunc(sorted, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) + return sorted +} + // registerMacroName returns the macro name for registering an optional method. // For package "websocket" and method "OnClose", returns "register_websocket_close". func registerMacroName(pkg, name string) string { @@ -662,6 +703,11 @@ func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) { buf.WriteString("//! This crate provides type definitions, traits, and registration macros\n") buf.WriteString("//! for implementing Navidrome plugin capabilities in Rust.\n\n") + // Re-export the shared types so generated registration macros can reference them + // via $crate::types::X. The macro expands in the downstream plugin crate, which + // depends on the umbrella nd-pdk crate and not on nd-pdk-types directly. + buf.WriteString("pub use nd_pdk_types as types;\n\n") + // Module declarations for _, cap := range capabilities { moduleName := ToSnakeCase(cap.Name) @@ -887,3 +933,70 @@ func GeneratePDKTypesStub(symbols *PDKSymbols) ([]byte, error) { return buf.Bytes(), nil } + +// GenerateSharedTypesRust generates the nd-pdk-types crate root (lib.rs). +func GenerateSharedTypesRust(structs []StructDef) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/types.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading types rust template: %w", err) + } + sorted := sortedStructs(structs) + known := map[string]bool{} + for _, s := range sorted { + known[s.Name] = true + } + tmpl, err := template.New("types_rs").Funcs(template.FuncMap{ + "rustDocComment": RustDocComment, + "rustFieldName": func(n string) string { return ToSnakeCase(n) }, + "fieldRustType": func(f FieldDef) string { return f.RustType(known) }, + "skipSerializingFunc": skipSerializingFunc, + "indent": indentSpaces, + }).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading base64_bytes partial: %w", err) + } + tmpl, err = tmpl.Parse(string(partialContent)) + if err != nil { + return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) + } + + data := struct { + Structs []StructDef + HasHashMap bool + HasByteFields bool + }{Structs: sorted, HasHashMap: anyFieldUsesHashMap(sorted), HasByteFields: anyFieldIsByteSlice(sorted)} + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + return buf.Bytes(), nil +} + +// GenerateSharedTypesGo generates the shared `types` package (plain data structs). +func GenerateSharedTypesGo(structs []StructDef, pkgName string) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/types.go.tmpl") + if err != nil { + return nil, fmt.Errorf("reading types template: %w", err) + } + tmpl, err := template.New("types").Funcs(template.FuncMap{ + "formatDoc": formatDoc, + "indent": indentText, + }).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + data := struct { + Package string + Structs []StructDef + }{Package: pkgName, Structs: sortedStructs(structs)} + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + return buf.Bytes(), nil +} diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 34c2c2886..15b97e1ae 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -287,6 +287,42 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`"encoding/json"`)) Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) }) + + It("imports the shared types package when a method references types directly", func() { + svc := Service{ + Name: "Matcher", + Interface: "MatcherService", + Methods: []Method{ + { + Name: "MatchSongs", + HasError: true, + Params: []Param{NewParam("songs", "[]types.SongRef")}, + Returns: []Param{NewParam("results", "[]*types.Track")}, + }, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) + Expect(codeStr).To(ContainSubstring("Songs []types.SongRef")) + }) + + It("does not import the shared types package when no method references types", func() { + svc := Service{ + Name: "Test", + Interface: "TestService", + Methods: []Method{ + {Name: "Method", Params: []Param{NewParam("count", "int32")}}, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + Expect(string(code)).NotTo(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) + }) }) Describe("toJSONName", func() { @@ -418,266 +454,19 @@ var _ = Describe("Generator", func() { }) }) - Describe("Python type and name helpers", func() { - Describe("ToPythonType", func() { - It("should map Go types to Python types", func() { - Expect(ToPythonType("string")).To(Equal("str")) - Expect(ToPythonType("int")).To(Equal("int")) - Expect(ToPythonType("int32")).To(Equal("int")) - Expect(ToPythonType("int64")).To(Equal("int")) - Expect(ToPythonType("float32")).To(Equal("float")) - Expect(ToPythonType("float64")).To(Equal("float")) - Expect(ToPythonType("bool")).To(Equal("bool")) - Expect(ToPythonType("[]byte")).To(Equal("bytes")) - Expect(ToPythonType("unknown")).To(Equal("Any")) - }) + Describe("ToSnakeCase", func() { + It("should convert PascalCase to snake_case", func() { + Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring")) + Expect(ToSnakeCase("GetString")).To(Equal("get_string")) + Expect(ToSnakeCase("simple")).To(Equal("simple")) }) - Describe("ToSnakeCase", func() { - It("should convert PascalCase to snake_case", func() { - Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring")) - Expect(ToSnakeCase("GetString")).To(Equal("get_string")) - Expect(ToSnakeCase("simple")).To(Equal("simple")) - }) - - It("should handle acronyms correctly", func() { - Expect(ToSnakeCase("ID")).To(Equal("id")) - Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id")) - Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id")) - Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser")) - Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response")) - }) - }) - - Describe("Method.PythonFunctionName", func() { - It("should generate snake_case function name with service prefix", func() { - m := Method{Name: "GetString"} - Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string")) - }) - }) - - Describe("Param.PythonType", func() { - It("should return Python type for parameter", func() { - p := NewParam("value", "string") - Expect(p.PythonType()).To(Equal("str")) - }) - }) - - Describe("Param.PythonName", func() { - It("should return snake_case name for parameter", func() { - p := NewParam("ttlSeconds", "int64") - Expect(p.PythonName()).To(Equal("ttl_seconds")) - }) - }) - }) - - Describe("GenerateClientPython", func() { - It("should generate valid Python code for a simple service", func() { - svc := Service{ - Name: "SubsonicAPI", - Permission: "subsonicapi", - Interface: "SubsonicAPIService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("responseJSON", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for generated header - Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - - // Check for imports - Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass")) - Expect(codeStr).To(ContainSubstring("import extism")) - Expect(codeStr).To(ContainSubstring("import json")) - - // Check for exception class - Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):")) - - // Check for raw import function - Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`)) - Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:")) - - // Check for wrapper function with type hints - Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:")) - - // Check for error handling - Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response[")) - }) - - It("should generate dataclass for multi-value returns", func() { - svc := Service{ - Name: "Cache", - Permission: "cache", - Interface: "CacheService", - Methods: []Method{ - { - Name: "GetString", - HasError: true, - Params: []Param{NewParam("key", "string")}, - Returns: []Param{ - NewParam("value", "string"), - NewParam("exists", "bool"), - }, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for dataclass - Expect(codeStr).To(ContainSubstring("@dataclass")) - Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:")) - Expect(codeStr).To(ContainSubstring("value: str")) - Expect(codeStr).To(ContainSubstring("exists: bool")) - - // Check that function returns dataclass - Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:")) - Expect(codeStr).To(ContainSubstring("return CacheGetStringResult(")) - }) - - It("should handle methods with no parameters", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoParams", - HasError: true, - Returns: []Param{NewParam("result", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Function with no params - Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:")) - // Empty request - Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`)) - }) - - It("should handle methods with no return values", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoReturn", - HasError: true, - Params: []Param{NewParam("input", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Function returns None - Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:")) - }) - - It("should generate correct Python defaults for different types", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "AllTypes", - HasError: true, - Returns: []Param{ - NewParam("strVal", "string"), - NewParam("intVal", "int64"), - NewParam("floatVal", "float64"), - NewParam("boolVal", "bool"), - }, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check defaults in response.get() calls - Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`)) - Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`)) - Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`)) - Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`)) - }) - - It("should not import base64 for non-byte services", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("response", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - Expect(codeStr).NotTo(ContainSubstring("import base64")) - }) - - It("should generate base64 encoding/decoding for byte fields", func() { - svc := Service{ - Name: "Codec", - Permission: "codec", - Interface: "CodecService", - Methods: []Method{ - { - Name: "Encode", - HasError: true, - Params: []Param{NewParam("data", "[]byte")}, - Returns: []Param{NewParam("result", "[]byte")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should import base64 - Expect(codeStr).To(ContainSubstring("import base64")) - - // Should base64-encode byte params in request - Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`)) - - // Should base64-decode byte returns in response - Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`)) + It("should handle acronyms correctly", func() { + Expect(ToSnakeCase("ID")).To(Equal("id")) + Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id")) + Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id")) + Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser")) + Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response")) }) }) @@ -1186,6 +975,73 @@ type TestService interface { Expect(codeStr).To(ContainSubstring("ScrobblerErrorRetry ScrobblerErrorType =")) Expect(codeStr).To(ContainSubstring(`"retry"`)) }) + + It("emits a deprecated alias and types import for a shared-aliased capability", func() { + cap := Capability{ + Name: "scrobbler", + Interface: "Scrobbler", + Required: true, + Methods: []Export{{ + Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}, + }}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}, + }}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{{Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + code, err := GenerateCapabilityGo(cap, "scrobbler") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("// Deprecated: use types.TrackInfo.")) + Expect(out).To(ContainSubstring("type TrackInfo = types.TrackInfo")) + Expect(out).NotTo(ContainSubstring("type TrackInfo struct")) + }) + + It("emits the types import for a direct types.X field with no deprecated alias", func() { + cap := Capability{ + Name: "scrobbler", + Interface: "Scrobbler", + Required: true, + Methods: []Export{{ + Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}, + }}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Song", Type: "types.SongRef", JSONTag: "song"}, + }}}, + // No SharedAliases: the field references the canonical type directly. + } + code, err := GenerateCapabilityGo(cap, "scrobbler") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("types.SongRef")) + }) + + It("emits the types import for a direct types.X method input/output", func() { + cap := Capability{ + Name: "demo", + Interface: "Demo", + Required: true, + Methods: []Export{{ + Name: "Lookup", ExportName: "nd_demo_lookup", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}, + }}, + // No structs, no aliases: the method signature references the shared type directly. + } + code, err := GenerateCapabilityGo(cap, "demo") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("types.SongRef")) + }) }) Describe("GenerateCapabilityGoStub", func() { @@ -1287,14 +1143,67 @@ type OnInitOutput struct { }) }) +var _ = Describe("Shared Types Generation", func() { + It("emits a Rust types crate root with serde derives", func() { + structs := []StructDef{ + {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ + {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, + {Name: "Name", Type: "string", JSONTag: "name"}, + }}, + } + code, err := GenerateSharedTypesRust(structs) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("use serde::{Deserialize, Serialize};")) + Expect(out).To(ContainSubstring("pub struct ArtistRef {")) + Expect(out).To(ContainSubstring(`#[serde(rename_all = "camelCase")]`)) + Expect(out).To(ContainSubstring("pub name: String,")) + }) + + It("emits a flat Go types package with no imports", func() { + structs := []StructDef{ + {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ + {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, + {Name: "Name", Type: "string", JSONTag: "name"}, + }}, + } + code, err := GenerateSharedTypesGo(structs, "types") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("package types")) + Expect(out).To(ContainSubstring("type ArtistRef struct {")) + Expect(out).To(ContainSubstring("ID string `json:\"id,omitempty\"`")) + Expect(out).To(ContainSubstring("Name string `json:\"name\"`")) + Expect(out).NotTo(ContainSubstring("import")) + }) + + It("emits base64 serde for Vec fields in the Rust types crate", func() { + structs := []StructDef{ + {Name: "Payload", Doc: "Payload carries raw bytes.", Fields: []FieldDef{ + {Name: "Data", Type: "[]byte", JSONTag: "data"}, + }}, + } + code, err := GenerateSharedTypesRust(structs) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("mod base64_bytes")) + Expect(out).To(ContainSubstring("use base64::Engine as _")) + Expect(out).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) + }) +}) + var _ = Describe("Rust Generation", func() { Describe("skipSerializingFunc", func() { - It("should return Option::is_none for pointer, slice, and map types", func() { + It("should return Option::is_none for pointer types", func() { Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none")) Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none")) + }) + + It("should return the matching emptiness predicate for slice and map types", func() { + // The predicate must match the rendered Rust type: []T -> Vec, map[K]V -> HashMap. + Expect(skipSerializingFunc("[]string")).To(Equal("Vec::is_empty")) + Expect(skipSerializingFunc("[]int32")).To(Equal("Vec::is_empty")) + Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty")) }) It("should return String::is_empty for string type", func() { @@ -1482,6 +1391,109 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("Option<")) }) + It("translates a shared type used directly as a method input/output", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}}}, + // No structs, no aliases: the method signature references the shared type directly. + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // The shared type must resolve to the canonical crate path, not pass through + // as the invalid Go selector `types.SongRef`. + Expect(out).To(ContainSubstring("nd_pdk_types::SongRef")) + Expect(out).NotTo(ContainSubstring("types.SongRef")) + }) + + It("routes shared macro types through $crate so umbrella-crate plugins resolve them", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}}}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Inside the export macro (expanded in the downstream plugin crate, which depends + // on the umbrella nd-pdk only), the shared type must be reachable via $crate, not + // by naming the transitive nd_pdk_types crate directly. + Expect(out).To(ContainSubstring("extism_pdk::Json<$crate::types::SongRef>")) + Expect(out).NotTo(ContainSubstring("extism_pdk::Json")) + }) + + It("emits a deprecated Rust type alias for shared types", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Note points authors at the umbrella path (nd-pdk re-exports nd_pdk_types as `types`); + // the alias target stays the real crate path so it resolves inside nd-pdk-capabilities. + Expect(out).To(ContainSubstring(`#[deprecated(note = "use nd_pdk::types::TrackInfo")]`)) + Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::TrackInfo;")) + }) + + It("keeps struct-field type when the type name is a shared alias (regression: was serde_json::Value)", func() { + // Wrapper has a field whose type is only in SharedAliases, not Structs. + // The field must render as `pub track: nd_pdk_types::TrackInfo` (canonical + // path), not as the local deprecated alias and not as serde_json::Value. + cap := Capability{ + Name: "test", Interface: "TestAgent", Required: true, + Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", + Input: Param{Name: "req", Type: "Wrapper"}}}, + Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Field must use the canonical nd_pdk_types:: path, not the local alias. + Expect(out).To(ContainSubstring("nd_pdk_types::TrackInfo")) + Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) + Expect(out).NotTo(ContainSubstring("pub track: TrackInfo,")) + }) + + It("renders a qualified types.X field as nd_pdk_types::X and keeps the renamed re-export", func() { + // The capability references the shared type by its canonical qualified + // name (types.Track) while the deprecated alias keeps the old name. + cap := Capability{ + Name: "test", Interface: "TestAgent", Required: true, + Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", + Input: Param{Name: "req", Type: "Wrapper"}}}, + Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ + {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Doc: "Deprecated: use types.Track.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Field uses the canonical qualified path (resolved from the types. prefix). + Expect(out).To(ContainSubstring("pub track: nd_pdk_types::Track,")) + // The deprecated alias is still re-exported under its kept name. + Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::Track;")) + Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) + }) + It("should include all float types correctly", func() { cap := Capability{ Name: "test", @@ -1633,6 +1645,36 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) }) + It("resolves a shared alias used in a method param/return to its canonical crate path", func() { + svc := Service{ + Name: "Matcher", + Permission: "matcher", + Interface: "MatcherService", + Methods: []Method{ + { + Name: "MatchSongs", + HasError: true, + Params: []Param{NewParam("query", "string")}, + // Return uses the deprecated alias name directly. + Returns: []Param{NewParam("matches", "[]Track")}, + }, + }, + SharedAliases: []SharedAlias{{ + Name: "Track", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + + code, err := GenerateClientRust(svc) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // The alias must resolve to the shared crate type; a bare `Track` is undefined + // in nd-pdk-host and would not compile. + Expect(out).To(ContainSubstring("nd_pdk_types::Track")) + Expect(out).NotTo(ContainSubstring("Vec")) + }) + It("should not generate base64 module when no byte fields", func() { svc := Service{ Name: "Test", diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 4cb28f8d4..9f305efec 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -27,31 +27,86 @@ var ( keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`) ) -// ParseDirectory parses all Go source files in a directory and extracts host services. -func ParseDirectory(dir string) ([]Service, error) { +// parsedGoFile pairs a source path with its already-parsed AST. +type parsedGoFile struct { + path string + file *ast.File +} + +// parseGoFiles returns the eligible Go source files in dir, each parsed once. +func parseGoFiles(dir string, fset *token.FileSet) ([]parsedGoFile, error) { + paths, err := goSourceFiles(dir) + if err != nil { + return nil, err + } + out := make([]parsedGoFile, 0, len(paths)) + for _, p := range paths { + f, err := parser.ParseFile(fset, p, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(p), err) + } + out = append(out, parsedGoFile{path: p, file: f}) + } + return out, nil +} + +// goSourceFiles returns the Go source file paths in dir, excluding generated, +// test, and doc files. +func goSourceFiles(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("reading directory: %w", err) } - - var services []Service - fset := token.NewFileSet() - + var paths []string for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") { continue } - // Skip generated files and test files - if strings.HasSuffix(entry.Name(), "_gen.go") || strings.HasSuffix(entry.Name(), "_test.go") { + if strings.HasSuffix(name, "_gen.go") || strings.HasSuffix(name, "_test.go") || name == "doc.go" { continue } + paths = append(paths, filepath.Join(dir, name)) + } + return paths, nil +} - path := filepath.Join(dir, entry.Name()) - parsed, err := parseFile(fset, path) - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err) +// ParseDirectory parses all Go source files in a directory and extracts host services. +func ParseDirectory(dir string) ([]Service, error) { + return ParseDirectoryWithShared(dir, nil) +} + +// ParseDirectoryWithShared parses all Go source files in a directory, resolving any +// type aliases that reference the shared `types` package against the provided registry. +func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Service, error) { + fset := token.NewFileSet() + parsed, err := parseGoFiles(dir, fset) + if err != nil { + return nil, err + } + + // First pass: collect all struct definitions and type aliases from every file + // so that a struct or alias declared in one file is visible when resolving + // types in a sibling file. + pkgStructMap := make(map[string]StructDef) + pkgAliasMap := make(map[string]TypeAlias) + for _, pf := range parsed { + for _, s := range parseStructs(pf.file) { + pkgStructMap[s.Name] = s } - services = append(services, parsed...) + for _, a := range parseTypeAliases(pf.file) { + pkgAliasMap[a.Name] = a + } + } + + // Second pass: parse services using the package-level maps. + var services []Service + for _, pf := range parsed { + svcList, err := parseServiceFile(pf.file, pkgStructMap, pkgAliasMap, shared) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) + } + services = append(services, svcList...) } return services, nil @@ -59,66 +114,72 @@ func ParseDirectory(dir string) ([]Service, error) { // ParseCapabilities parses all Go source files in a directory and extracts capabilities. func ParseCapabilities(dir string) ([]Capability, error) { - entries, err := os.ReadDir(dir) + return ParseCapabilitiesWithShared(dir, nil) +} + +// ParseCapabilitiesWithShared parses all Go source files in a directory, resolving any +// type aliases that reference the shared `types` package against the provided registry. +func ParseCapabilitiesWithShared(dir string, shared map[string]StructDef) ([]Capability, error) { + fset := token.NewFileSet() + parsed, err := parseGoFiles(dir, fset) if err != nil { - return nil, fmt.Errorf("reading directory: %w", err) + return nil, err } - fset := token.NewFileSet() - - // First pass: collect all structs and type aliases from all files in the package - sharedStructMap := make(map[string]StructDef) - sharedAliasMap := make(map[string]TypeAlias) + // First pass: collect all structs, type aliases, and const groups. + pkgStructMap := make(map[string]StructDef) + pkgAliasMap := make(map[string]TypeAlias) var allConstGroups []ConstGroup - var goFiles []string - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { - continue + for _, pf := range parsed { + for _, s := range parseStructs(pf.file) { + pkgStructMap[s.Name] = s } - // Skip generated files, test files, and doc.go - if strings.HasSuffix(entry.Name(), "_gen.go") || - strings.HasSuffix(entry.Name(), "_test.go") || - entry.Name() == "doc.go" { - continue + for _, a := range parseTypeAliases(pf.file) { + pkgAliasMap[a.Name] = a } - goFiles = append(goFiles, filepath.Join(dir, entry.Name())) + allConstGroups = append(allConstGroups, parseConstGroups(pf.file)...) } - for _, path := range goFiles { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parsing %s for types: %w", filepath.Base(path), err) - } - for _, s := range parseStructs(f) { - sharedStructMap[s.Name] = s - } - for _, a := range parseTypeAliases(f) { - sharedAliasMap[a.Name] = a - } - allConstGroups = append(allConstGroups, parseConstGroups(f)...) - } - - // Second pass: parse capabilities using the shared type maps + // Second pass: parse capabilities using the package-level type maps. var capabilities []Capability - for _, path := range goFiles { - parsed, err := parseCapabilityFile(fset, path, sharedStructMap, sharedAliasMap, allConstGroups) + for _, pf := range parsed { + capList, err := parseCapabilityFile(pf.path, pf.file, pkgStructMap, pkgAliasMap, allConstGroups, shared) if err != nil { - return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) } - capabilities = append(capabilities, parsed...) + capabilities = append(capabilities, capList...) } return capabilities, nil } -// parseCapabilityFile parses a single Go source file and extracts capabilities. -func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup) ([]Capability, error) { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, err +// LoadSharedTypes parses every struct defined in dir (the shared `types` source +// package) and returns them keyed by name. dir == "" yields an empty map. +func LoadSharedTypes(dir string) (map[string]StructDef, error) { + result := map[string]StructDef{} + if dir == "" { + return result, nil } + paths, err := goSourceFiles(dir) + if err != nil { + return nil, fmt.Errorf("reading shared types directory: %w", err) + } + fset := token.NewFileSet() + for _, path := range paths { + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) + } + for _, s := range parseStructs(f) { + result[s.Name] = s + } + } + return result, nil +} +// parseCapabilityFile parses a single Go source file and extracts capabilities. +func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup, shared map[string]StructDef) ([]Capability, error) { var capabilities []Capability for _, decl := range f.Decls { @@ -190,6 +251,21 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] // Recursively collect all struct dependencies collectAllStructDependencies(referencedTypes, structMap) + // Resolve shared-type aliases against the registry + sharedAliases, sharedTypes, err := resolveSharedAliases(referencedTypes, aliasMap, shared) + if err != nil { + return nil, err + } + capability.SharedAliases = sharedAliases + capability.SharedTypes = sharedTypes + + // Build a set of names already covered by SharedAliases so we don't + // emit them again in TypeAliases (which would cause a redeclaration). + sharedAliasNames := make(map[string]bool, len(capability.SharedAliases)) + for _, sa := range capability.SharedAliases { + sharedAliasNames[sa.Name] = true + } + // Sort type names for stable output order sortedTypeNames := slices.Sorted(maps.Keys(referencedTypes)) @@ -200,8 +276,11 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] } } - // Attach referenced type aliases + // Attach referenced type aliases (skip those already in SharedAliases) for _, typeName := range sortedTypeNames { + if sharedAliasNames[typeName] { + continue + } if a, exists := aliasMap[typeName]; exists { capability.TypeAliases = append(capability.TypeAliases, a) } @@ -211,6 +290,9 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] // This supports error types that are not directly referenced in method signatures interfaceName := typeSpec.Name.Name for _, typeName := range slices.Sorted(maps.Keys(aliasMap)) { + if sharedAliasNames[typeName] { + continue + } a := aliasMap[typeName] if strings.HasPrefix(typeName, interfaceName) && !referencedTypes[typeName] { capability.TypeAliases = append(capability.TypeAliases, a) @@ -237,6 +319,111 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] return capabilities, nil } +// resolveSharedAliases determines which shared `types` package structs a host +// service or capability uses and returns the deprecated re-export aliases to emit +// for them. +// +// A shared type counts as used when a field references it by qualified name +// (e.g. types.Track) or via a declared alias used by bare name (e.g. a field of +// type TrackInfo where `type TrackInfo = types.Track`). The shared struct's own +// fields are followed transitively so nested shared types are picked up too. For +// every used canonical type, each declared `type X = types.Canonical` alias is +// emitted as a SharedAlias so the generated PDK keeps re-exporting it for +// backwards compatibility. +// +// It returns the deprecated re-export aliases to emit and the resolved shapes of +// every used shared type (alias or not, for schema inlining). +// +// Returns an error if a referenced shared type cannot be found in the shared registry. +func resolveSharedAliases(referenced map[string]bool, aliasMap map[string]TypeAlias, shared map[string]StructDef) ([]SharedAlias, []StructDef, error) { + // Index declared shared aliases by the canonical type they target, e.g. + // "Track" -> [TrackInfo]. A canonical type may have more than one alias. + aliasesByCanonical := map[string][]TypeAlias{} + for _, a := range aliasMap { + if a.IsSharedAlias() { + canonical := strings.TrimPrefix(a.Type, sharedTypesPrefix) + aliasesByCanonical[canonical] = append(aliasesByCanonical[canonical], a) + } + } + + // Walk the referenced types, following nested shared references inside the + // shared structs, to find the set of canonical shared types used. + used := map[string]bool{} + var queue []string + for name := range referenced { + if c, ok := seedSharedCanonical(name, aliasMap); ok { + queue = append(queue, c) + } + } + fieldRefs := map[string]bool{} + for len(queue) > 0 { + canonical := queue[0] + queue = queue[1:] + if used[canonical] { + continue + } + def, ok := shared[canonical] + if !ok { + return nil, nil, fmt.Errorf( + "shared type %q could not be resolved: pass -shared= pointing at the shared types package, and ensure %s is defined there", + canonical, sharedTypesPrefix+canonical, + ) + } + used[canonical] = true + for _, f := range def.Fields { + clear(fieldRefs) + collectReferencedTypes(f.Type, fieldRefs) + for t := range fieldRefs { + if c, ok := nestedSharedCanonical(t, shared); ok { + queue = append(queue, c) + } + } + } + } + + var out []SharedAlias + var usedDefs []StructDef + for canonical := range used { + usedDefs = append(usedDefs, shared[canonical]) + for _, a := range aliasesByCanonical[canonical] { + out = append(out, SharedAlias{Name: a.Name, Target: a.Type, Doc: a.Doc, Def: shared[canonical]}) + } + } + slices.SortFunc(out, func(a, b SharedAlias) int { return strings.Compare(a.Name, b.Name) }) + slices.SortFunc(usedDefs, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) + return out, usedDefs, nil +} + +// seedSharedCanonical maps a type token referenced by a capability/service field +// to the canonical shared type it denotes. It recognizes qualified references +// (types.X -> X) and declared shared aliases used by bare name (X where +// `type X = types.Y` -> Y). A bare name that is not a declared shared alias is +// not treated as shared, so a local struct sharing a name with a shared type is +// never misclassified. +func seedSharedCanonical(name string, aliasMap map[string]TypeAlias) (string, bool) { + if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { + return rest, true + } + if a, ok := aliasMap[name]; ok && a.IsSharedAlias() { + return strings.TrimPrefix(a.Type, sharedTypesPrefix), true + } + return "", false +} + +// nestedSharedCanonical maps a type token found inside a shared struct's own +// fields to a canonical shared type. Within the shared package, types reference +// each other by bare name (e.g. Track.Artists is []ArtistRef), so any bare name +// present in the shared registry counts. +func nestedSharedCanonical(name string, shared map[string]StructDef) (string, bool) { + if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { + return rest, true + } + if _, ok := shared[name]; ok { + return name, true + } + return "", false +} + // collectAllStructDependencies recursively collects all struct types referenced by other structs. func collectAllStructDependencies(referencedTypes map[string]bool, structMap map[string]StructDef) { // Keep iterating until no new types are added @@ -301,20 +488,11 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri return export, nil } -// parseFile parses a single Go source file and extracts host services. -func parseFile(fset *token.FileSet, path string) ([]Service, error) { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, err - } - - // First pass: collect all struct definitions in the file - allStructs := parseStructs(f) - structMap := make(map[string]StructDef) - for _, s := range allStructs { - structMap[s.Name] = s - } - +// parseServiceFile parses a single Go source file and extracts host services. +// pkgStructMap and pkgAliasMap are the package-wide struct and alias maps built +// from all files in the package, so a host-service interface can reference types +// defined in a sibling file. +func parseServiceFile(f *ast.File, pkgStructMap map[string]StructDef, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) { var services []Service for _, decl := range f.Decls { @@ -382,9 +560,22 @@ func parseFile(fset *token.FileSet, path string) ([]Service, error) { } } + // Resolve shared-type aliases against the registry. Host-service schemas + // are not generated (the -schemas pass is capability-only), so the resolved + // shared shapes are not needed here. + sharedAliases, _, err := resolveSharedAliases(referencedTypes, pkgAliasMap, shared) + if err != nil { + return nil, err + } + service.SharedAliases = sharedAliases + + // Recursively collect all struct dependencies so types referenced only + // transitively (e.g. a field type of a referenced struct) are attached. + collectAllStructDependencies(referencedTypes, pkgStructMap) + // Attach referenced structs to the service (sorted for stable output) for _, typeName := range slices.Sorted(maps.Keys(referencedTypes)) { - if s, exists := structMap[typeName]; exists { + if s, exists := pkgStructMap[typeName]; exists { service.Structs = append(service.Structs, s) } } @@ -469,9 +660,10 @@ func parseTypeAliases(f *ast.File) []TypeAlias { docText, _ := getDocComment(genDecl, typeSpec) aliases = append(aliases, TypeAlias{ - Name: typeSpec.Name.Name, - Type: typeToString(typeSpec.Type), - Doc: cleanDoc(docText), + Name: typeSpec.Name.Name, + Type: typeToString(typeSpec.Type), + Doc: cleanDoc(docText), + IsAlias: typeSpec.Assign.IsValid(), }) } } @@ -640,6 +832,14 @@ func collectReferencedTypes(goType string, refs map[string]bool) { return } + // Qualified reference to the shared `types` package (e.g. types.Track). + // These start with a lowercase package selector, so they must be collected + // before the uppercase check below would skip them. + if strings.HasPrefix(goType, sharedTypesPrefix) { + refs[goType] = true + return + } + // Check if it's a custom type (starts with uppercase, not a builtin) if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' { switch goType { diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f43578397..bfdb93db5 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -212,6 +212,136 @@ type RegularInterface interface { Expect(err).NotTo(HaveOccurred()) Expect(services).To(BeEmpty()) }) + + It("should resolve structs defined in a sibling file of the same package", func() { + // types.go defines Track and Artist — no host service here + typesSrc := `package host + +// Artist is a track participant. +type Artist struct { + // ID is the artist identifier. + ID string ` + "`json:\"id\"`" + ` + // Name is the artist name. + Name string ` + "`json:\"name\"`" + ` +} + +// Track is a media file projection. +type Track struct { + // ID is the track identifier. + ID string ` + "`json:\"id\"`" + ` + // Title is the track title. + Title string ` + "`json:\"title\"`" + ` + // Participants maps role to artists (transitive dependency test). + Participants map[string][]Artist ` + "`json:\"participants\"`" + ` +} +` + // service.go defines the host service that references Track from types.go + serviceSrc := `package host + +import "context" + +// MatcherService matches tracks. +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + // MatchTrack finds a matching track. + //nd:hostfunc + MatchTrack(ctx context.Context, t Track) (matched bool, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(typesSrc), 0600) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(serviceSrc), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + svc := services[0] + Expect(svc.Name).To(Equal("Matcher")) + Expect(svc.Methods).To(HaveLen(1)) + Expect(svc.Methods[0].Name).To(Equal("MatchTrack")) + + // Track must be resolved from the sibling file, not just the service file. + // Artist must also be transitively resolved (Track.Participants references it). + structNames := make([]string, len(svc.Structs)) + for i, s := range svc.Structs { + structNames[i] = s.Name + } + Expect(structNames).To(ConsistOf("Track", "Artist")) + }) + + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { + fileA := `package host + +import "github.com/navidrome/navidrome/plugins/types" + +type TrackInfo = types.TrackInfo +` + fileB := `package host + +import "context" + +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + //nd:hostfunc + Match(ctx context.Context, t TrackInfo) (bool, error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) + + _, err := ParseDirectoryWithShared(tmpDir, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TrackInfo")) + Expect(err.Error()).To(ContainSubstring("-shared")) + }) + + It("resolves shared-type aliases declared in a sibling file (package-wide alias map)", func() { + // File A: declares the shared-type alias in the same package + fileA := `package host + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.Track. +type Track = types.Track +` + // File B: declares the host service that references Track from file A + fileB := `package host + +import "context" + +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + //nd:hostfunc + MatchSongs(ctx context.Context, query string) (results []Track, err error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) + + shared := map[string]StructDef{ + "Track": { + Name: "Track", + Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artist", Type: "string", JSONTag: "artist"}, + }, + }, + } + + services, err := ParseDirectoryWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + byName := map[string]SharedAlias{} + for _, a := range services[0].SharedAliases { + byName[a.Name] = a + } + // Track alias is in a sibling file — must be resolved package-wide + Expect(byName).To(HaveKey("Track")) + Expect(byName["Track"].Target).To(Equal("types.Track")) + }) }) Describe("parseKeyValuePairs", func() { @@ -520,6 +650,182 @@ type Output struct { Expect(capabilities[0].Methods).To(HaveLen(1)) Expect(capabilities[0].Methods[0].Name).To(Equal("ExportedMethod")) }) + + It("distinguishes Go type aliases from defined types", func() { + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// ScrobblerError is a sentinel error string. +type ScrobblerError string + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_check + Check(ArtistRef) (bool, error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + } + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + // ArtistRef is a shared-type alias (types.*): it lands in SharedAliases, not TypeAliases. + sharedByName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + sharedByName[a.Name] = a + } + Expect(sharedByName).To(HaveKey("ArtistRef")) + Expect(sharedByName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + + // ScrobblerError is a plain defined type: it stays in TypeAliases. + typeByName := map[string]TypeAlias{} + for _, a := range caps[0].TypeAliases { + typeByName[a.Name] = a + } + Expect(typeByName).To(HaveKey("ScrobblerError")) + Expect(typeByName["ScrobblerError"].IsAlias).To(BeFalse()) + }) + }) + + Describe("ParseCapabilitiesWithShared", func() { + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// TrackInfo is an alias for the shared type. +type TrackInfo = types.TrackInfo + +//nd:capability name=nowplaying required=true +type NowPlaying interface { + //nd:export name=nd_now_playing + NowPlaying(TrackInfo) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "nowplaying.go"), []byte(src), 0600)).To(Succeed()) + + _, err := ParseCapabilitiesWithShared(tmpDir, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TrackInfo")) + Expect(err.Error()).To(ContainSubstring("-shared")) + }) + + It("resolves shared-type aliases against the registry", func() { + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + "TrackInfo": {Name: "TrackInfo", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, + }}, + } + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.TrackInfo. +type TrackInfo = types.TrackInfo + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// NowPlayingRequest carries a track. +type NowPlayingRequest struct { + Track TrackInfo ` + "`json:\"track\"`" + ` +} + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_now_playing + NowPlaying(NowPlayingRequest) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + names := []string{} + for _, a := range caps[0].SharedAliases { + names = append(names, a.Name) + } + // TrackInfo is referenced directly; ArtistRef is pulled in transitively via TrackInfo.Artists. + Expect(names).To(ContainElements("TrackInfo", "ArtistRef")) + + byName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + byName[a.Name] = a + } + Expect(byName["TrackInfo"].Target).To(Equal("types.TrackInfo")) + Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining + Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + }) + + It("resolves shared types from qualified types.X references with a renamed alias", func() { + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + "Track": {Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, + }}, + } + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.Track. +type TrackInfo = types.Track + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// NowPlayingRequest carries a track. +type NowPlayingRequest struct { + Track types.Track ` + "`json:\"track\"`" + ` +} + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_now_playing + NowPlaying(NowPlayingRequest) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + byName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + byName[a.Name] = a + } + // The deprecated alias keeps its name (TrackInfo) but now targets types.Track. + // ArtistRef is pulled in transitively via Track.Artists. + Expect(byName).To(HaveKey("TrackInfo")) + Expect(byName).To(HaveKey("ArtistRef")) + Expect(byName["TrackInfo"].Target).To(Equal("types.Track")) + Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining + Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + + // The capability struct field keeps the canonical qualified reference. + var nowPlaying StructDef + for _, st := range caps[0].Structs { + if st.Name == "NowPlayingRequest" { + nowPlaying = st + } + } + Expect(nowPlaying.Fields).To(HaveLen(1)) + Expect(nowPlaying.Fields[0].Type).To(Equal("types.Track")) + }) }) Describe("Export helpers", func() { diff --git a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl index ebcd80739..8b88f367c 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl @@ -8,9 +8,21 @@ package {{.Package}} import ( +{{- if .Capability.ImportsSharedTypes}} + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Capability.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} + {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 790ed93e4..2ee7ece1d 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -2,7 +2,7 @@ // // This file contains export wrappers for the {{.Capability.Interface}} capability. // It is intended for use in Navidrome plugins built with extism-pdk. -{{if .Capability.Structs}} +{{if or .Capability.Structs .Capability.SharedAliases}} use serde::{Deserialize, Serialize}; {{- if hasHashMap .Capability}} use std::collections::HashMap; @@ -24,6 +24,13 @@ fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } {{- end}} +{{- /* Generate deprecated aliases to the shared types crate */ -}} +{{- range .Capability.SharedAliases}} + +#[deprecated(note = "use {{rustSharedNote .Target}}")] +pub type {{.Name}} = {{rustSharedTarget .Target}}; +{{- end}} + {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} @@ -131,9 +138,9 @@ macro_rules! register_{{snakeCase .Package}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + req: extism_pdk::Json<{{rustMethodType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> { + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { let plugin = <$plugin_type>::default(); {{- if and .HasInput .HasOutput}} let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; @@ -178,9 +185,9 @@ macro_rules! {{registerMacroName .Name}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + req: extism_pdk::Json<{{rustMethodType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> { + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { let plugin = <$plugin_type>::default(); {{- if and .HasInput .HasOutput}} let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; diff --git a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl index 90f72be93..59a886f08 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl @@ -7,6 +7,19 @@ //go:build !wasip1 package {{.Package}} +{{- if .Capability.ImportsSharedTypes}} + +import "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} + +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Capability.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl index a6ee04446..b49680d1d 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -12,10 +12,23 @@ import ( {{- if .Service.HasErrors}} "errors" {{- end}} +{{- if .Service.ImportsSharedTypes}} + + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Service.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} + {{- /* Generate struct definitions */ -}} {{- range .Service.Structs}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl deleted file mode 100644 index 7ccaa6106..000000000 --- a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl +++ /dev/null @@ -1,111 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the {{.Service.Name}} host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -{{- if .Service.HasByteFields}} -import base64 -{{- end}} - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - -{{- /* Generate raw host function imports */ -}} -{{range .Service.Methods}} - - -@extism.import_fn("extism:host/user", "{{exportName .}}") -def _{{exportName .}}(offset: int) -> int: - """Raw host function - do not call directly.""" - ... -{{- end}} -{{- /* Generate dataclasses for multi-value returns */ -}} -{{range .Service.Methods}} -{{- if .NeedsResultClass}} - - -@dataclass -class {{pythonResultType .}}: - """Result type for {{pythonFunc .}}.""" -{{- range .Returns}} - {{.PythonName}}: {{.PythonType}} -{{- end}} -{{- end}} -{{- end}} -{{- /* Generate wrapper functions */ -}} -{{range .Service.Methods}} - - -def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: - """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}} -{{- if .HasParams}} - - Args: -{{- range .Params}} - {{.PythonName}}: {{.PythonType}} parameter. -{{- end}} -{{- end}} -{{- if .HasReturns}} - - Returns: -{{- if .NeedsResultClass}} - {{pythonResultType .}} containing{{range .Returns}} {{.PythonName}},{{end}}. -{{- else}} - {{(index .Returns 0).PythonType}}: The result value. -{{- end}} -{{- end}} - - Raises: - HostFunctionError: If the host function returns an error. - """ -{{- if .HasParams}} - request = { -{{- range .Params}} -{{- if .IsByteSlice}} - "{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"), -{{- else}} - "{{.JSONName}}": {{.PythonName}}, -{{- end}} -{{- end}} - } - request_bytes = json.dumps(request).encode("utf-8") -{{- else}} - request_bytes = b"{}" -{{- end}} - request_mem = extism.memory.alloc(request_bytes) - response_offset = _{{exportName .}}(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) -{{if .HasError}} - if response.get("error"): - raise HostFunctionError(response["error"]) -{{end}} -{{- if .NeedsResultClass}} - return {{pythonResultType .}}( -{{- range .Returns}} -{{- if .IsByteSlice}} - {{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")), -{{- else}} - {{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}), -{{- end}} -{{- end}} - ) -{{- else if .HasReturns}} -{{- if (index .Returns 0).IsByteSlice}} - return base64.b64decode(response.get("{{(index .Returns 0).JSONName}}", "")) -{{- else}} - return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}}) -{{- end}} -{{- end}} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl index da19df666..d2a8f3f27 100644 --- a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl @@ -8,7 +8,21 @@ package {{.Package}} -import "github.com/stretchr/testify/mock" +import ( +{{- if .Service.ImportsSharedTypes}} + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} + "github.com/stretchr/testify/mock" +) + +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Service.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} {{- /* Generate struct definitions (same as main file, needed for type references in function signatures) */ -}} {{- range .Service.Structs}} diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl index 083f7577e..b1ac4dd36 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -7,6 +7,10 @@ import ( "encoding/json" extism "github.com/extism/go-sdk" +{{- if .Service.ImportsSharedTypes}} + + "github.com/navidrome/navidrome/plugins/types" +{{- end}} ) {{- /* Generate request/response types for all methods */ -}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.go.tmpl b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl new file mode 100644 index 000000000..698429b96 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl @@ -0,0 +1,23 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// Package {{.Package}} holds the stable, shared data types exchanged between +// Navidrome and its plugins. These types are referenced by host services and +// capability wrappers via the types package. + +package {{.Package}} +{{- range .Structs}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- else}} +// {{.Name}} represents the {{.Name}} data structure. +{{- end}} +type {{.Name}} struct { +{{- range .Fields}} +{{- if .Doc}} +{{formatDoc .Doc | indent 1}} +{{- end}} + {{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"` +{{- end}} +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl new file mode 100644 index 000000000..b7bc131ff --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl @@ -0,0 +1,49 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +//! Navidrome shared plugin data types. + +use serde::{Deserialize, Serialize}; +{{- if .HasHashMap}} +use std::collections::HashMap; +{{- end}} +{{- if .HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +{{- range .Structs}} + +{{- if .Doc}} +{{rustDocComment .Doc}} +{{- else}} +/// {{.Name}} represents the {{.Name}} data structure. +{{- end}} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct {{.Name}} { +{{- range .Fields}} +{{- if .Doc}} +{{rustDocComment .Doc | indent 4}} +{{- end}} +{{- if .OmitEmpty}} + #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] +{{- else}} + #[serde(default)] +{{- end}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] +{{- end}} + pub {{rustFieldName .Name}}: {{fieldRustType .}}, +{{- end}} +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 6132dfbc4..09657d09e 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -5,34 +5,59 @@ import ( "unicode" ) +// sharedTypesPrefix is the package selector a capability or host-service source +// uses to reference the shared types package (e.g. types.Track). +const sharedTypesPrefix = "types." + // Service represents a parsed host service interface. type Service struct { - Name string // Service name from annotation (e.g., "SubsonicAPI") - Permission string // Manifest permission key (e.g., "subsonicapi") - Interface string // Go interface name (e.g., "SubsonicAPIService") - Methods []Method // Methods marked with //nd:hostfunc - Doc string // Documentation comment for the service - Structs []StructDef // Structs used by this service + Name string // Service name from annotation (e.g., "SubsonicAPI") + Permission string // Manifest permission key (e.g., "subsonicapi") + Interface string // Go interface name (e.g., "SubsonicAPIService") + Methods []Method // Methods marked with //nd:hostfunc + Doc string // Documentation comment for the service + Structs []StructDef // Structs used by this service + SharedAliases []SharedAlias // Aliases to types in the shared `types` package } // Capability represents a parsed capability interface for plugin exports. type Capability struct { - Name string // Package name from annotation (e.g., "metadata") - Interface string // Go interface name (e.g., "MetadataAgent") - Required bool // If true, all methods must be implemented - Methods []Export // Methods marked with //nd:export - Doc string // Documentation comment for the capability - Structs []StructDef // Structs used by this capability - TypeAliases []TypeAlias // Type aliases used by this capability - Consts []ConstGroup // Const groups used by this capability - SourceFile string // Base name of source file without extension (e.g., "websocket_callback") + Name string // Package name from annotation (e.g., "metadata") + Interface string // Go interface name (e.g., "MetadataAgent") + Required bool // If true, all methods must be implemented + Methods []Export // Methods marked with //nd:export + Doc string // Documentation comment for the capability + Structs []StructDef // Structs used by this capability + TypeAliases []TypeAlias // Type aliases used by this capability + Consts []ConstGroup // Const groups used by this capability + SourceFile string // Base name of source file without extension (e.g., "websocket_callback") + SharedAliases []SharedAlias // Aliases to types in the shared `types` package + SharedTypes []StructDef // Resolved shapes of every used shared type, keyed by canonical name (for schema inlining, alias or not) } -// TypeAlias represents a type alias definition (e.g., type ScrobblerErrorType string). +// TypeAlias represents a type declaration (e.g. type ScrobblerErrorType string) +// or a Go type alias (e.g. type TrackInfo = types.Track). type TypeAlias struct { - Name string // Type name - Type string // Underlying type - Doc string // Documentation comment + Name string // Type name + Type string // Underlying type (or alias target, e.g. "types.Track") + Doc string // Documentation comment + IsAlias bool // true for `type X = Y` (alias); false for `type X Y` (defined type) +} + +// IsDeprecated reports whether the alias carries a `Deprecated:` doc line. +func (t TypeAlias) IsDeprecated() bool { + for _, line := range strings.Split(t.Doc, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "Deprecated:") { + return true + } + } + return false +} + +// IsSharedAlias reports whether this alias targets the shared types package +// (e.g. `type TrackInfo = types.Track`). +func (t TypeAlias) IsSharedAlias() bool { + return t.IsAlias && strings.HasPrefix(t.Type, sharedTypesPrefix) } // ConstGroup represents a group of const definitions. @@ -48,12 +73,90 @@ type ConstDef struct { Doc string // Documentation comment } -// KnownStructs returns a map of struct names defined in this capability. +// SharedAlias is a deprecated alias from a capability/host package to a type in +// the shared `types` package (e.g. type TrackInfo = types.Track). Def is the +// resolved shared struct, kept for XTP schema inlining. +type SharedAlias struct { + Name string // local name, e.g. "TrackInfo" + Target string // alias target, e.g. "types.Track" + Doc string // doc comment (carries the Deprecated: line) + Def StructDef // resolved shared struct shape +} + +// ImportsSharedTypes reports whether this capability references the shared types package. +// A reference can come from a deprecated re-export alias (e.g. type SongRef = types.SongRef) +// or directly from the canonical form (e.g. types.SongRef) in a struct field or a method +// signature, so the generated Go import must be emitted even when no alias is declared. +func (c Capability) ImportsSharedTypes() bool { + if len(c.SharedAliases) > 0 || structsReferenceSharedTypes(c.Structs) { + return true + } + for _, m := range c.Methods { + if typeReferencesSharedTypes(m.Input.Type) || typeReferencesSharedTypes(m.Output.Type) { + return true + } + } + return false +} + +// ImportsSharedTypes reports whether this service references the shared types package. +func (s Service) ImportsSharedTypes() bool { + if len(s.SharedAliases) > 0 || structsReferenceSharedTypes(s.Structs) { + return true + } + for _, m := range s.Methods { + for _, p := range m.Params { + if typeReferencesSharedTypes(p.Type) { + return true + } + } + for _, r := range m.Returns { + if typeReferencesSharedTypes(r.Type) { + return true + } + } + } + return false +} + +// structsReferenceSharedTypes reports whether any field across the given structs +// refers to the shared types package by its qualified name (e.g. types.SongRef, +// []types.SongRef, map[string]types.SongRef). +func structsReferenceSharedTypes(structs []StructDef) bool { + for _, st := range structs { + for _, f := range st.Fields { + if typeReferencesSharedTypes(f.Type) { + return true + } + } + } + return false +} + +// typeReferencesSharedTypes reports whether a Go type expression refers to the +// shared types package by its qualified name, accounting for pointer, slice, and +// map wrappers (e.g. types.SongRef, []types.SongRef, map[string]types.SongRef). +func typeReferencesSharedTypes(goType string) bool { + refs := map[string]bool{} + collectReferencedTypes(goType, refs) + for t := range refs { + if strings.HasPrefix(t, sharedTypesPrefix) { + return true + } + } + return false +} + +// KnownStructs returns a map of struct names defined in this capability, +// including shared-alias names so Rust field-type resolution finds them. func (c Capability) KnownStructs() map[string]bool { result := make(map[string]bool) for _, st := range c.Structs { result[st.Name] = true } + for _, sa := range c.SharedAliases { + result[sa.Name] = true + } return result } @@ -154,12 +257,16 @@ func (s Service) ExportPrefix() string { return strings.ToLower(s.Name) } -// KnownStructs returns a map of struct names defined in this service. +// KnownStructs returns a map of struct names defined in this service, +// including shared-alias names so Rust field-type resolution finds them. func (s Service) KnownStructs() map[string]bool { result := make(map[string]bool) for _, st := range s.Structs { result[st.Name] = true } + for _, sa := range s.SharedAliases { + result[sa.Name] = true + } return result } @@ -427,24 +534,6 @@ func toJSONName(name string) string { return string(result) } -// ToPythonType converts a Go type to its Python equivalent. -func ToPythonType(goType string) string { - switch goType { - case "string": - return "str" - case "int", "int32", "int64": - return "int" - case "float32", "float64": - return "float" - case "bool": - return "bool" - case "[]byte": - return "bytes" - default: - return "Any" - } -} - // ToSnakeCase converts a PascalCase or camelCase string to snake_case. // It handles consecutive uppercase letters correctly (e.g., "ScheduleID" -> "schedule_id"). func ToSnakeCase(s string) string { @@ -469,31 +558,6 @@ func ToSnakeCase(s string) string { return strings.ToLower(result.String()) } -// PythonFunctionName returns the Python function name for a method. -func (m Method) PythonFunctionName(servicePrefix string) string { - return ToSnakeCase(servicePrefix + m.Name) -} - -// PythonResultTypeName returns the Python dataclass name for multi-value returns. -func (m Method) PythonResultTypeName(serviceName string) string { - return serviceName + m.Name + "Result" -} - -// NeedsResultClass returns true if the method needs a dataclass for returns. -func (m Method) NeedsResultClass() bool { - return len(m.Returns) > 1 -} - -// PythonType returns the Python type for this parameter. -func (p Param) PythonType() string { - return ToPythonType(p.Type) -} - -// PythonName returns the snake_case Python name for this parameter. -func (p Param) PythonName() string { - return ToSnakeCase(p.Name) -} - // ToRustType converts a Go type to its Rust equivalent. func ToRustType(goType string) string { return ToRustTypeWithStructs(goType, nil) @@ -562,6 +626,13 @@ func (p Param) RustTypeWithStructs(knownStructs map[string]bool) string { return ToRustTypeWithStructs(p.Type, knownStructs) } +// RustTypeWithShared returns the Rust type, resolving shared-alias names to their +// canonical nd_pdk_types::X crate path (e.g. a return of []Track where +// type Track = types.Track renders as Vec). +func (p Param) RustTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { + return ToRustTypeWithShared(p.Type, knownStructs, shared) +} + // RustParamType returns the Rust type for this parameter when used as a function argument. func (p Param) RustParamType() string { return RustParamType(p.Type) @@ -575,6 +646,15 @@ func (p Param) RustParamTypeWithStructs(knownStructs map[string]bool) string { return ToRustTypeWithStructs(p.Type, knownStructs) } +// RustParamTypeWithShared returns the Rust param type, resolving shared-alias +// names to their canonical nd_pdk_types::X crate path. +func (p Param) RustParamTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { + if p.Type == "string" { + return "&str" + } + return ToRustTypeWithShared(p.Type, knownStructs, shared) +} + // RustName returns the snake_case Rust name for this parameter. func (p Param) RustName() string { return ToSnakeCase(p.Name) @@ -604,9 +684,18 @@ func (f FieldDef) NeedsDefault() bool { // ToRustTypeWithStructs converts a Go type to its Rust equivalent, // using known struct names instead of serde_json::Value. func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { + return toRustType(goType, knownStructs, nil) +} + +// ToRustTypeWithShared resolves shared-alias names to their canonical nd_pdk_types::X path. +func ToRustTypeWithShared(goType string, knownStructs map[string]bool, shared map[string]string) string { + return toRustType(goType, knownStructs, shared) +} + +func toRustType(goType string, knownStructs map[string]bool, shared map[string]string) string { // Handle pointer types if strings.HasPrefix(goType, "*") { - inner := ToRustTypeWithStructs(goType[1:], knownStructs) + inner := toRustType(goType[1:], knownStructs, shared) return "Option<" + inner + ">" } // Handle slice types @@ -614,7 +703,7 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { if goType == "[]byte" { return "Vec" } - inner := ToRustTypeWithStructs(goType[2:], knownStructs) + inner := toRustType(goType[2:], knownStructs, shared) return "Vec<" + inner + ">" } // Handle map types @@ -636,7 +725,7 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { } keyType := rest[:keyEnd] valueType := rest[keyEnd+1:] - return "std::collections::HashMap<" + ToRustTypeWithStructs(keyType, knownStructs) + ", " + ToRustTypeWithStructs(valueType, knownStructs) + ">" + return "std::collections::HashMap<" + toRustType(keyType, knownStructs, shared) + ", " + toRustType(valueType, knownStructs, shared) + ">" } switch goType { @@ -659,6 +748,17 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { case "interface{}", "any": return "serde_json::Value" default: + // Qualified reference to the shared types crate (e.g. types.Track -> + // nd_pdk_types::Track). + if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { + return "nd_pdk_types::" + rest + } + // Resolve shared-alias names to their canonical nd_pdk_types:: path. + if shared != nil { + if t, ok := shared[goType]; ok { + return t + } + } // Check if this is a known struct type if knownStructs != nil && knownStructs[goType] { return goType diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index cc2a7d0e0..20c067ffa 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -60,16 +60,18 @@ type ( func GenerateSchema(cap Capability) ([]byte, error) { schema := xtpSchema{Version: "v1-draft"} + aliasToCanonical := buildAliasToCanonical(cap) + // Build exports as ordered map if len(cap.Methods) > 0 { schema.Exports = yaml.Node{Kind: yaml.MappingNode} for _, export := range cap.Methods { - addToMap(&schema.Exports, export.ExportName, buildExport(export)) + addToMap(&schema.Exports, export.ExportName, buildExport(export, aliasToCanonical)) } } // Build components/schemas - schemas := buildSchemas(cap) + schemas := buildSchemas(cap, aliasToCanonical) if len(schemas.Content) > 0 { schema.Components = &xtpComponents{Schemas: schemas} } @@ -77,11 +79,22 @@ func GenerateSchema(cap Capability) ([]byte, error) { return yaml.Marshal(schema) } -func buildExport(export Export) xtpExport { +// buildAliasToCanonical maps each deprecated shared-alias name to the canonical +// shared type it targets (e.g. TrackInfo -> Track). Schema components are emitted +// under the canonical name, so every $ref site must resolve through this map. +func buildAliasToCanonical(cap Capability) map[string]string { + m := map[string]string{} + for _, a := range cap.SharedAliases { + m[a.Name] = strings.TrimPrefix(a.Target, sharedTypesPrefix) + } + return m +} + +func buildExport(export Export, aliasToCanonical map[string]string) xtpExport { e := xtpExport{Description: cleanDocForYAML(export.Doc)} if export.Input.Type != "" { e.Input = &xtpIOParam{ - Ref: "#/components/schemas/" + strings.TrimPrefix(export.Input.Type, "*"), + Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(export.Input.Type), aliasToCanonical), ContentType: "application/json", } } @@ -95,7 +108,7 @@ func buildExport(export Export) xtpExport { } } else { e.Output = &xtpIOParam{ - Ref: "#/components/schemas/" + outputType, + Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(outputType), aliasToCanonical), ContentType: "application/json", } } @@ -112,15 +125,38 @@ func isPrimitiveGoType(goType string) bool { return false } -func buildSchemas(cap Capability) yaml.Node { +func buildSchemas(cap Capability, aliasToCanonical map[string]string) yaml.Node { schemas := yaml.Node{Kind: yaml.MappingNode} knownTypes := cap.KnownStructs() for _, alias := range cap.TypeAliases { knownTypes[alias.Name] = true } + // Register shared types under their canonical name (e.g. types.Track -> Track) + // and stash their struct shapes for inlining. SharedTypes covers every used + // shared type, including ones referenced directly as types.X with no declared + // deprecated alias; SharedAliases is folded in for completeness. + sharedDefs := map[string]StructDef{} + for _, def := range cap.SharedTypes { + knownTypes[def.Name] = true + sharedDefs[def.Name] = def + } + for _, a := range cap.SharedAliases { + canonical := strings.TrimPrefix(a.Target, sharedTypesPrefix) + knownTypes[canonical] = true + sharedDefs[canonical] = a.Def + } + // Collect types that are actually used by exports - usedTypes := collectUsedTypes(cap, knownTypes) + usedTypes := collectUsedTypes(cap, knownTypes, sharedDefs) + + // A used alias name (e.g. TrackInfo) implies its canonical component (Track) is + // used, since the alias-typed field's $ref resolves to the canonical name. + for alias, canonical := range aliasToCanonical { + if usedTypes[alias] { + usedTypes[canonical] = true + } + } // Sort structs by name for consistent output structNames := make([]string, 0, len(cap.Structs)) @@ -135,7 +171,19 @@ func buildSchemas(cap Capability) yaml.Node { for _, name := range structNames { st := structMap[name] - addToMap(&schemas, name, buildObjectSchema(st, knownTypes)) + addToMap(&schemas, name, buildObjectSchema(st, knownTypes, aliasToCanonical)) + } + + // Emit components for used shared aliases (sorted for deterministic output). + sharedNames := make([]string, 0, len(sharedDefs)) + for name, def := range sharedDefs { + if usedTypes[name] && len(def.Fields) > 0 { + sharedNames = append(sharedNames, name) + } + } + sort.Strings(sharedNames) + for _, name := range sharedNames { + addToMap(&schemas, name, buildObjectSchema(sharedDefs[name], knownTypes, aliasToCanonical)) } // Build enum types from type aliases (only if used by exports) @@ -157,18 +205,18 @@ func buildSchemas(cap Capability) yaml.Node { } // collectUsedTypes returns a set of type names that are reachable from exports. -func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]bool { +func collectUsedTypes(cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef) map[string]bool { used := make(map[string]bool) // Start with types directly referenced by exports for _, export := range cap.Methods { if export.Input.Type != "" { - addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, used) + addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, sharedDefs, used) } if export.Output.Type != "" { outputType := strings.TrimPrefix(export.Output.Type, "*") if !isPrimitiveGoType(outputType) { - addTypeAndDeps(outputType, cap, knownTypes, used) + addTypeAndDeps(outputType, cap, knownTypes, sharedDefs, used) } } } @@ -177,28 +225,55 @@ func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]boo } // addTypeAndDeps adds a type and all its dependencies to the used set. -func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, used map[string]bool) { +func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef, used map[string]bool) { + typeName = strings.TrimPrefix(typeName, sharedTypesPrefix) if used[typeName] || !knownTypes[typeName] { return } used[typeName] = true - // Find the struct and add its field types + // Walk fields of capability-local structs. for _, st := range cap.Structs { if st.Name == typeName { for _, field := range st.Fields { - fieldType := strings.TrimPrefix(field.Type, "*") - fieldType = strings.TrimPrefix(fieldType, "[]") - if knownTypes[fieldType] { - addTypeAndDeps(fieldType, cap, knownTypes, used) + if base := fieldBaseType(field.Type); knownTypes[base] { + addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) } } return } } + + // Walk fields of shared structs so their nested refs are also marked used. + if def, ok := sharedDefs[typeName]; ok { + for _, field := range def.Fields { + if base := fieldBaseType(field.Type); knownTypes[base] { + addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) + } + } + } } -func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema { +// fieldBaseType reduces a field type to the base named type used for schema +// lookups: it strips a leading pointer/slice and any shared `types.` selector. +func fieldBaseType(goType string) string { + goType = strings.TrimPrefix(goType, "*") + goType = strings.TrimPrefix(goType, "[]") + return strings.TrimPrefix(goType, sharedTypesPrefix) +} + +// canonicalRefName resolves a deprecated shared-alias name to the canonical type +// the schema component is emitted under (e.g. TrackInfo -> Track). Non-alias +// names pass through unchanged, so $ref targets always point at an emitted +// component instead of a dangling alias name. +func canonicalRefName(name string, aliasToCanonical map[string]string) string { + if canonical, ok := aliasToCanonical[name]; ok { + return canonical + } + return name +} + +func buildObjectSchema(st StructDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpObjectSchema { schema := xtpObjectSchema{ Description: cleanDocForYAML(st.Doc), Properties: yaml.Node{Kind: yaml.MappingNode}, @@ -206,7 +281,7 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema for _, field := range st.Fields { propName := getJSONFieldName(field) - addToMap(&schema.Properties, propName, buildProperty(field, knownTypes)) + addToMap(&schema.Properties, propName, buildProperty(field, knownTypes, aliasToCanonical)) if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty { schema.Required = append(schema.Required, propName) @@ -228,7 +303,7 @@ func buildEnumSchema(alias TypeAlias, cg ConstGroup) xtpEnumSchema { } } -func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { +func buildProperty(field FieldDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpProperty { goType := field.Type isPointer := strings.HasPrefix(goType, "*") if isPointer { @@ -240,9 +315,10 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { Nullable: isPointer, } - // Handle reference types (use $ref instead of type) - if isKnownType(goType, knownTypes) && !strings.HasPrefix(goType, "[]") { - prop.Ref = "#/components/schemas/" + goType + // Handle reference types (use $ref instead of type). Qualified shared + // references (types.X) are referenced by their canonical name. + if refType := strings.TrimPrefix(goType, sharedTypesPrefix); isKnownType(refType, knownTypes) && !strings.HasPrefix(goType, "[]") { + prop.Ref = "#/components/schemas/" + canonicalRefName(refType, aliasToCanonical) return prop } @@ -254,11 +330,11 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { // Handle slice types if strings.HasPrefix(goType, "[]") { - elemType := goType[2:] + elemType := strings.TrimPrefix(goType[2:], sharedTypesPrefix) prop.Type = "array" prop.Items = &xtpProperty{} if isKnownType(elemType, knownTypes) { - prop.Items.Ref = "#/components/schemas/" + elemType + prop.Items.Ref = "#/components/schemas/" + canonicalRefName(elemType, aliasToCanonical) } else { prop.Items.Type = goTypeToXTPType(elemType) } diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index 2e28a75d8..f8702aa4d 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -700,6 +700,115 @@ var _ = Describe("XTP Schema Generation", func() { }) }) + Describe("GenerateSchema with shared aliases", func() { + It("inlines shared-alias shapes as schema components", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + Expect(string(out)).To(ContainSubstring("TrackInfo:")) + Expect(string(out)).To(ContainSubstring("title:")) + }) + + It("names the shared component by its canonical type for qualified types.X fields", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // Component is named by the canonical type (Track), not the deprecated alias. + Expect(s).To(ContainSubstring("Track:")) + Expect(s).NotTo(ContainSubstring("TrackInfo:")) + // The field $ref points at the canonical component. + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).To(ContainSubstring("title:")) + }) + + It("points an alias-named field at the canonical component for a renamed alias", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", + Input: Param{Name: "input", Type: "PlayRequest"}}}, + Structs: []StructDef{{Name: "PlayRequest", Fields: []FieldDef{ + // Field is typed with the deprecated alias name, not the canonical types.Track. + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // The component is emitted under the canonical name, and the field $ref must + // point at it — not at a non-existent TrackInfo component (dangling reference). + Expect(s).To(ContainSubstring("Track:")) + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) + }) + + It("points an alias-named export input/output at the canonical component", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + // The method takes/returns the deprecated alias name directly. + Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", + Input: Param{Name: "input", Type: "TrackInfo"}, + Output: Param{Name: "output", Type: "TrackInfo"}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // Export $ref must resolve to the canonical component, not a missing TrackInfo. + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) + }) + + It("inlines a directly-referenced shared type that has no deprecated alias", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Song", Type: "types.SongRef", JSONTag: "song"}}}}, + // No SharedAliases: the field references the canonical type directly. + SharedTypes: []StructDef{{Name: "SongRef", Fields: []FieldDef{ + {Name: "Name", Type: "string", JSONTag: "name"}}}}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + Expect(s).To(ContainSubstring("SongRef:")) + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/SongRef'")) + Expect(s).To(ContainSubstring("name:")) + }) + }) + Describe("GenerateSchema enum filtering", func() { It("should only include enums that are actually used by exports", func() { capability := Capability{ diff --git a/plugins/cmd/ndpgen/main.go b/plugins/cmd/ndpgen/main.go index b34ee4296..6520a3ecd 100644 --- a/plugins/cmd/ndpgen/main.go +++ b/plugins/cmd/ndpgen/main.go @@ -19,7 +19,7 @@ // // Output directories: // - Host wrappers: $input/_gen.go (server-side, used by Navidrome) -// - Host functions: $output/go/host/, $output/python/host/, $output/rust/host/ +// - Host functions: $output/go/host/, $output/rust/host/ // - Capabilities: $output/go// (e.g., $output/go/metadata/) // - Schemas: $input/.yaml (co-located with Go sources) // @@ -32,8 +32,7 @@ // -host-only Generate PDK client wrappers for calling host functions // -capability-only Generate only capability export wrappers // -schemas Generate XTP YAML schemas from capabilities -// -go Generate Go client wrappers (default: true when not using -python/-rust) -// -python Generate Python client wrappers (default: false) +// -go Generate Go client wrappers (default: true when not using -rust) // -rust Generate Rust client wrappers (default: false) // -v Verbose output // -dry-run Preview generated code without writing files @@ -43,8 +42,10 @@ import ( "flag" "fmt" "go/format" + "maps" "os" "path/filepath" + "slices" "strings" "github.com/navidrome/navidrome/plugins/cmd/ndpgen/internal" @@ -55,16 +56,16 @@ type config struct { inputDir string outputDir string // Base output directory (e.g., plugins/pdk) goOutputDir string // Go output: $outputDir/go/host (for host-only) - pythonOutputDir string // Python output: $outputDir/python/host rustOutputDir string // Rust output: $outputDir/rust/host pkgName string hostOnly bool hostWrappers bool // Generate host wrappers (used by Navidrome server) capabilityOnly bool - schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) - pdkOnly bool // Generate PDK abstraction layer wrapper + schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) + pdkOnly bool // Generate PDK abstraction layer wrapper + sharedTypes bool // Generate the shared types package + sharedDir string // Directory of shared types to load as a registry generateGoClient bool - generatePyClient bool generateRsClient bool verbose bool dryRun bool @@ -85,6 +86,14 @@ func main() { return } + if cfg.sharedTypes { + if err := runSharedTypesGeneration(cfg); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + if cfg.pdkOnly { if err := runPDKGeneration(cfg); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -157,6 +166,69 @@ func runSchemaGeneration(cfg *config) error { return generateSchemas(cfg, capabilities) } +// writeGenerated creates dir (if needed) and writes content to name inside it. +// In dry-run mode it prints the content instead. +func writeGenerated(dir, name string, content []byte, dryRun, verbose bool) error { + path := filepath.Join(dir, name) + if dryRun { + fmt.Printf("=== %s ===\n%s\n", path, content) + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating output directory %s: %w", dir, err) + } + if err := os.WriteFile(path, content, 0600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + if verbose { + fmt.Printf("Generated: %s\n", path) + } + return nil +} + +// runSharedTypesGeneration handles shared types package generation. +func runSharedTypesGeneration(cfg *config) error { + structs, err := internal.LoadSharedTypes(cfg.inputDir) + if err != nil { + return err + } + if len(structs) == 0 { + return nil + } + list := slices.Collect(maps.Values(structs)) + if cfg.generateGoClient { + code, err := internal.GenerateSharedTypesGo(list, "types") + if err != nil { + return fmt.Errorf("generating Go types: %w", err) + } + formatted, err := format.Source(code) + if err != nil { + return fmt.Errorf("formatting Go types: %w\n%s", err, code) + } + dir := filepath.Join(cfg.outputDir, "go", "types") + if err := writeGenerated(dir, "types.go", formatted, cfg.dryRun, cfg.verbose); err != nil { + return err + } + } + if cfg.generateRsClient { + if err := generateSharedTypesRust(list, cfg); err != nil { + return err + } + } + return nil +} + +// generateSharedTypesRust writes the nd-pdk-types crate root to +// /rust/nd-pdk-types/src/lib.rs. +func generateSharedTypesRust(structs []internal.StructDef, cfg *config) error { + code, err := internal.GenerateSharedTypesRust(structs) + if err != nil { + return fmt.Errorf("generating Rust types: %w", err) + } + dir := filepath.Join(cfg.outputDir, "rust", "nd-pdk-types", "src") + return writeGenerated(dir, "lib.rs", code, cfg.dryRun, cfg.verbose) +} + // runPDKGeneration handles PDK abstraction layer code generation. // This generates the pdk wrapper package that wraps extism/go-pdk // with mockable implementations for unit testing on native platforms. @@ -315,8 +387,9 @@ func parseConfig() (*config, error) { capabilityOnly = flag.Bool("capability-only", false, "Generate only capability export wrappers") schemasOnly = flag.Bool("schemas", false, "Generate XTP YAML schemas from capabilities (output to input directory)") pdkOnly = flag.Bool("extism-pdk", false, "Generate PDK abstraction layer by parsing extism/go-pdk") + sharedTypes = flag.Bool("shared-types", false, "Generate the shared types package") + shared = flag.String("shared", "", "Directory of shared types to load as a registry") goClient = flag.Bool("go", false, "Generate Go client wrappers") - pyClient = flag.Bool("python", false, "Generate Python client wrappers") rsClient = flag.Bool("rust", false, "Generate Rust client wrappers") verbose = flag.Bool("v", false, "Verbose output") dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files") @@ -340,6 +413,9 @@ func parseConfig() (*config, error) { if *pdkOnly { modeCount++ } + if *sharedTypes { + modeCount++ + } // Default to host-only if no mode is specified if modeCount == 0 { @@ -348,7 +424,7 @@ func parseConfig() (*config, error) { // Cannot specify multiple modes if modeCount > 1 { - return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -pdk)") + return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -extism-pdk, -shared-types)") } if *outputDir == "" { @@ -372,23 +448,28 @@ func parseConfig() (*config, error) { return nil, fmt.Errorf("resolving output path: %w", err) } + absShared := "" + if *shared != "" { + absShared, err = filepath.Abs(*shared) + if err != nil { + return nil, fmt.Errorf("resolving shared path: %w", err) + } + } + // Set output directories for each language // Go host wrappers: $output/go/host/ - // Python host wrappers: $output/python/host/ // Rust host wrappers: $output/rust/nd-pdk-host/ (renamed crate) absGoOutput := filepath.Join(absOutput, "go", "host") - absPythonOutput := filepath.Join(absOutput, "python", "host") absRustOutput := filepath.Join(absOutput, "rust", "nd-pdk-host") // Determine what to generate // Default: generate Go clients if no language flag is specified - anyLangFlag := *goClient || *pyClient || *rsClient + anyLangFlag := *goClient || *rsClient return &config{ inputDir: absInput, outputDir: absOutput, goOutputDir: absGoOutput, - pythonOutputDir: absPythonOutput, rustOutputDir: absRustOutput, pkgName: *pkgName, hostOnly: *hostOnly, @@ -396,8 +477,9 @@ func parseConfig() (*config, error) { capabilityOnly: *capabilityOnly, schemasOnly: *schemasOnly, pdkOnly: *pdkOnly, + sharedTypes: *sharedTypes, + sharedDir: absShared, generateGoClient: *goClient || !anyLangFlag, - generatePyClient: *pyClient, generateRsClient: *rsClient, verbose: *verbose, dryRun: *dryRun, @@ -412,20 +494,21 @@ func parseServices(cfg *config) ([]internal.Service, error) { if cfg.generateGoClient { fmt.Printf("Go output directory: %s\n", cfg.goOutputDir) } - if cfg.generatePyClient { - fmt.Printf("Python output directory: %s\n", cfg.pythonOutputDir) - } if cfg.generateRsClient { fmt.Printf("Rust output directory: %s\n", cfg.rustOutputDir) } fmt.Printf("Package name: %s\n", cfg.pkgName) fmt.Printf("Host-only mode: %v\n", cfg.hostOnly) fmt.Printf("Generate Go client code: %v\n", cfg.generateGoClient) - fmt.Printf("Generate Python client code: %v\n", cfg.generatePyClient) fmt.Printf("Generate Rust client code: %v\n", cfg.generateRsClient) } - services, err := internal.ParseDirectory(cfg.inputDir) + shared, err := internal.LoadSharedTypes(cfg.sharedDir) + if err != nil { + return nil, fmt.Errorf("loading shared types: %w", err) + } + + services, err := internal.ParseDirectoryWithShared(cfg.inputDir, shared) if err != nil { return nil, fmt.Errorf("parsing source files: %w", err) } @@ -455,7 +538,12 @@ func parseCapabilities(cfg *config) ([]internal.Capability, error) { fmt.Printf("Capability-only mode: %v\n", cfg.capabilityOnly) } - capabilities, err := internal.ParseCapabilities(cfg.inputDir) + shared, err := internal.LoadSharedTypes(cfg.sharedDir) + if err != nil { + return nil, fmt.Errorf("loading shared types: %w", err) + } + + capabilities, err := internal.ParseCapabilitiesWithShared(cfg.inputDir, shared) if err != nil { return nil, fmt.Errorf("parsing capability files: %w", err) } @@ -619,11 +707,6 @@ func generateAllCode(cfg *config, services []internal.Service) error { return fmt.Errorf("generating Go client code for %s: %w", svc.Name, err) } } - if cfg.generatePyClient { - if err := generatePythonClientCode(svc, cfg.pythonOutputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Python client code for %s: %w", svc.Name, err) - } - } if cfg.generateRsClient { if err := generateRustClientCode(svc, cfg.rustOutputDir, cfg.dryRun, cfg.verbose); err != nil { return fmt.Errorf("generating Rust client code for %s: %w", svc.Name, err) @@ -757,36 +840,6 @@ func generateGoClientStubCode(svc internal.Service, outputDir, pkgName string, d return nil } -// generatePythonClientCode generates Python client-side code for a service. -func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { - code, err := internal.GenerateClientPython(svc) - if err != nil { - return fmt.Errorf("generating code: %w", err) - } - - // Python code goes directly in the output directory - clientFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+".py") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", clientFile, code) - return nil - } - - // Create output directory if needed - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating python client directory: %w", err) - } - - if err := os.WriteFile(clientFile, code, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated Python client code: %s\n", clientFile) - } - return nil -} - // generateRustClientCode generates Rust client-side code for a service. func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { code, err := internal.GenerateClientRust(svc) diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.py b/plugins/cmd/ndpgen/testdata/codec_client_expected.py deleted file mode 100644 index 5142ffd0e..000000000 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.py +++ /dev/null @@ -1,53 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Codec host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "codec_encode") -def _codec_encode(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def codec_encode(data: bytes) -> bytes: - """Call the codec_encode host function. - - Args: - data: bytes parameter. - - Returns: - bytes: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _codec_encode(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py deleted file mode 100644 index 93370ddcf..000000000 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py +++ /dev/null @@ -1,342 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Comprehensive host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "comprehensive_simpleparams") -def _comprehensive_simpleparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_structparam") -def _comprehensive_structparam(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_mixedparams") -def _comprehensive_mixedparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noerror") -def _comprehensive_noerror(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noparams") -def _comprehensive_noparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noparamsnoreturns") -def _comprehensive_noparamsnoreturns(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_pointerparams") -def _comprehensive_pointerparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_mapparams") -def _comprehensive_mapparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_multiplereturns") -def _comprehensive_multiplereturns(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_byteslice") -def _comprehensive_byteslice(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ComprehensiveMultipleReturnsResult: - """Result type for comprehensive_multiple_returns.""" - results: Any - total: int - - -def comprehensive_simple_params(name: str, count: int) -> str: - """Call the comprehensive_simpleparams host function. - - Args: - name: str parameter. - count: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "count": count, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_simpleparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def comprehensive_struct_param(user: Any) -> None: - """Call the comprehensive_structparam host function. - - Args: - user: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "user": user, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_structparam(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_mixed_params(id: str, filter: Any) -> int: - """Call the comprehensive_mixedparams host function. - - Args: - id: str parameter. - filter: Any parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_mixedparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) - - -def comprehensive_no_error(name: str) -> str: - """Call the comprehensive_noerror host function. - - Args: - name: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noerror(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def comprehensive_no_params() -> None: - """Call the comprehensive_noparams host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_no_params_no_returns() -> None: - """Call the comprehensive_noparamsnoreturns host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noparamsnoreturns(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_pointer_params(id: Any, user: Any) -> Any: - """Call the comprehensive_pointerparams host function. - - Args: - id: Any parameter. - user: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "user": user, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_pointerparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def comprehensive_map_params(data: Any) -> Any: - """Call the comprehensive_mapparams host function. - - Args: - data: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": data, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_mapparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def comprehensive_multiple_returns(query: str) -> ComprehensiveMultipleReturnsResult: - """Call the comprehensive_multiplereturns host function. - - Args: - query: str parameter. - - Returns: - ComprehensiveMultipleReturnsResult containing results, total,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "query": query, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_multiplereturns(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return ComprehensiveMultipleReturnsResult( - results=response.get("results", None), - total=response.get("total", 0), - ) - - -def comprehensive_byte_slice(data: bytes) -> bytes: - """Call the comprehensive_byteslice host function. - - Args: - data: bytes parameter. - - Returns: - bytes: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_byteslice(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/config_client_expected.py b/plugins/cmd/ndpgen/testdata/config_client_expected.py deleted file mode 100644 index 370de6d10..000000000 --- a/plugins/cmd/ndpgen/testdata/config_client_expected.py +++ /dev/null @@ -1,126 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Config host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "config_get") -def _config_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_set") -def _config_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_has") -def _config_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ConfigGetResult: - """Result type for config_get.""" - value: str - exists: bool - - -def config_get(key: str) -> ConfigGetResult: - """Call the config_get host function. - - Args: - key: str parameter. - - Returns: - ConfigGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return ConfigGetResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def config_set(key: str, value: str) -> None: - """Call the config_set host function. - - Args: - key: str parameter. - value: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def config_has(key: str) -> bool: - """Call the config_has host function. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) diff --git a/plugins/cmd/ndpgen/testdata/counter_client_expected.py b/plugins/cmd/ndpgen/testdata/counter_client_expected.py deleted file mode 100644 index 872d407bb..000000000 --- a/plugins/cmd/ndpgen/testdata/counter_client_expected.py +++ /dev/null @@ -1,49 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Counter host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "counter_count") -def _counter_count(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def counter_count(name: str) -> int: - """Call the counter_count host function. - - Args: - name: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _counter_count(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return response.get("value", 0) diff --git a/plugins/cmd/ndpgen/testdata/echo_client_expected.py b/plugins/cmd/ndpgen/testdata/echo_client_expected.py deleted file mode 100644 index 06565b0d6..000000000 --- a/plugins/cmd/ndpgen/testdata/echo_client_expected.py +++ /dev/null @@ -1,52 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Echo host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "echo_echo") -def _echo_echo(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def echo_echo(message: str) -> str: - """Call the echo_echo host function. - - Args: - message: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "message": message, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _echo_echo(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("reply", "") diff --git a/plugins/cmd/ndpgen/testdata/list_client_expected.py b/plugins/cmd/ndpgen/testdata/list_client_expected.py deleted file mode 100644 index 58ccad146..000000000 --- a/plugins/cmd/ndpgen/testdata/list_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the List host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "list_items") -def _list_items(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def list_items(name: str, filter: Any) -> int: - """Call the list_items host function. - - Args: - name: str parameter. - filter: Any parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _list_items(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("count", 0) diff --git a/plugins/cmd/ndpgen/testdata/math_client_expected.py b/plugins/cmd/ndpgen/testdata/math_client_expected.py deleted file mode 100644 index f3ea53335..000000000 --- a/plugins/cmd/ndpgen/testdata/math_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Math host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "math_add") -def _math_add(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def math_add(a: int, b: int) -> int: - """Call the math_add host function. - - Args: - a: int parameter. - b: int parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "a": a, - "b": b, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _math_add(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) diff --git a/plugins/cmd/ndpgen/testdata/meta_client_expected.py b/plugins/cmd/ndpgen/testdata/meta_client_expected.py deleted file mode 100644 index 4d20c73ff..000000000 --- a/plugins/cmd/ndpgen/testdata/meta_client_expected.py +++ /dev/null @@ -1,81 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Meta host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "meta_get") -def _meta_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "meta_set") -def _meta_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def meta_get(key: str) -> Any: - """Call the meta_get host function. - - Args: - key: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _meta_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("value", None) - - -def meta_set(data: Any) -> None: - """Call the meta_set host function. - - Args: - data: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": data, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _meta_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/cmd/ndpgen/testdata/ping_client_expected.py b/plugins/cmd/ndpgen/testdata/ping_client_expected.py deleted file mode 100644 index 4c7d41d8e..000000000 --- a/plugins/cmd/ndpgen/testdata/ping_client_expected.py +++ /dev/null @@ -1,42 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Ping host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "ping_ping") -def _ping_ping(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def ping_ping() -> None: - """Call the ping_ping host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _ping_ping(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/cmd/ndpgen/testdata/search_client_expected.py b/plugins/cmd/ndpgen/testdata/search_client_expected.py deleted file mode 100644 index aa2e98a36..000000000 --- a/plugins/cmd/ndpgen/testdata/search_client_expected.py +++ /dev/null @@ -1,62 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Search host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "search_find") -def _search_find(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class SearchFindResult: - """Result type for search_find.""" - results: Any - total: int - - -def search_find(query: str) -> SearchFindResult: - """Call the search_find host function. - - Args: - query: str parameter. - - Returns: - SearchFindResult containing results, total,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "query": query, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _search_find(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return SearchFindResult( - results=response.get("results", None), - total=response.get("total", 0), - ) diff --git a/plugins/cmd/ndpgen/testdata/store_client_expected.py b/plugins/cmd/ndpgen/testdata/store_client_expected.py deleted file mode 100644 index 4a964a497..000000000 --- a/plugins/cmd/ndpgen/testdata/store_client_expected.py +++ /dev/null @@ -1,52 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Store host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "store_save") -def _store_save(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def store_save(item: Any) -> str: - """Call the store_save host function. - - Args: - item: Any parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "item": item, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _store_save(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("id", "") diff --git a/plugins/cmd/ndpgen/testdata/users_client_expected.py b/plugins/cmd/ndpgen/testdata/users_client_expected.py deleted file mode 100644 index 468b87b98..000000000 --- a/plugins/cmd/ndpgen/testdata/users_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Users host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "users_get") -def _users_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def users_get(id: Any, filter: Any) -> Any: - """Call the users_get host function. - - Args: - id: Any parameter. - filter: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs index 12bf9ed3e..10c8f8c66 100644 --- a/plugins/examples/discord-rich-presence-rs/src/lib.rs +++ b/plugins/examples/discord-rich-presence-rs/src/lib.rs @@ -18,7 +18,7 @@ use extism_pdk::*; use nd_pdk::host::{artwork, config, scheduler}; use nd_pdk::scrobbler::{ - Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, + Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER, }; use nd_pdk::scheduler::{ @@ -207,6 +207,11 @@ impl Scrobbler for DiscordPlugin { // Discord Rich Presence doesn't need scrobble events - success Ok(()) } + + fn playback_report(&self, _req: PlaybackReportRequest) -> Result<(), ScrobblerError> { + // Discord Rich Presence doesn't need playback reports - success + Ok(()) + } } // ============================================================================ diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index e872d845d..743c03744 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -14,7 +14,7 @@ use extism_pdk::{config, error, http, info, warn, HttpRequest}; use nd_pdk::scrobbler::{ - Error, IsAuthorizedRequest, NowPlayingRequest, ScrobbleRequest, + Error, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, }; @@ -45,6 +45,15 @@ impl Scrobbler for WebhookPlugin { Ok(()) } + /// Handles playback state reports. This plugin ignores them (webhooks only on scrobble). + fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error> { + info!( + "Playback report (ignored): {} - {} for user {} (state: {})", + req.track.artist, req.track.title, req.username, req.state + ); + Ok(()) + } + /// Handles scrobble events by sending HTTP GET requests to configured URLs. fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { // Get configured URLs diff --git a/plugins/host/matcher.go b/plugins/host/matcher.go new file mode 100644 index 000000000..678d3bfaa --- /dev/null +++ b/plugins/host/matcher.go @@ -0,0 +1,27 @@ +package host + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/types" +) + +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + // Username runs the match as that user (case-insensitive): their favourites and + // ratings inform tiebreaking, and the returned tracks carry their annotations. + Username string `json:"username,omitempty"` +} + +// MatcherService resolves externally-obtained songs to local library tracks, +// reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). +// +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + // MatchSongs resolves each input song to its best-matching library track. + // It returns one entry per input song, in the same order as the input; the + // entry for an input song that had no match is empty (absent). Results are + // limited to the libraries the plugin (and the scoped user, if any) can access. + //nd:hostfunc + MatchSongs(ctx context.Context, songs []types.SongRef, opts MatchOptions) (results []*types.Track, err error) +} diff --git a/plugins/host/matcher_gen.go b/plugins/host/matcher_gen.go new file mode 100644 index 000000000..1bcd0dc3b --- /dev/null +++ b/plugins/host/matcher_gen.go @@ -0,0 +1,91 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" + + "github.com/navidrome/navidrome/plugins/types" +) + +// MatcherMatchSongsRequest is the request type for Matcher.MatchSongs. +type MatcherMatchSongsRequest struct { + Songs []types.SongRef `json:"songs"` + Opts MatchOptions `json:"opts"` +} + +// MatcherMatchSongsResponse is the response type for Matcher.MatchSongs. +type MatcherMatchSongsResponse struct { + Results []*types.Track `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterMatcherHostFunctions registers Matcher service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterMatcherHostFunctions(service MatcherService) []extism.HostFunction { + return []extism.HostFunction{ + newMatcherMatchSongsHostFunction(service), + } +} + +func newMatcherMatchSongsHostFunction(service MatcherService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "matcher_matchsongs", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + matcherWriteError(p, stack, err) + return + } + var req MatcherMatchSongsRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + matcherWriteError(p, stack, err) + return + } + + // Call the service method + results, svcErr := service.MatchSongs(ctx, req.Songs, req.Opts) + if svcErr != nil { + matcherWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := MatcherMatchSongsResponse{ + Results: results, + } + matcherWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// matcherWriteResponse writes a JSON response to plugin memory. +func matcherWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + matcherWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// matcherWriteError writes an error response to plugin memory. +func matcherWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_matcher.go b/plugins/host_matcher.go new file mode 100644 index 000000000..125d16649 --- /dev/null +++ b/plugins/host_matcher.go @@ -0,0 +1,198 @@ +package plugins + +import ( + "cmp" + "context" + "fmt" + "maps" + "slices" + "time" + + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/plugins/types" + "github.com/navidrome/navidrome/utils/slice" +) + +type matcherServiceImpl struct { + ds model.DataStore + hasFilesystemPerm bool + users userAccess + libs libraryAccess +} + +func newMatcherService(ds model.DataStore, hasFilesystemPerm bool, users userAccess, libs libraryAccess) host.MatcherService { + return &matcherServiceImpl{ + ds: ds, + hasFilesystemPerm: hasFilesystemPerm, + users: users, + libs: libs, + } +} + +func (s *matcherServiceImpl) MatchSongs(ctx context.Context, songs []types.SongRef, opts host.MatchOptions) ([]*types.Track, error) { + results := make([]*types.Track, len(songs)) + if len(songs) == 0 { + return results, nil + } + + // Fail closed when the plugin has no library scope, rather than matching nothing. + if !s.libs.configured() { + return nil, fmt.Errorf("matcher: no libraries configured for this plugin") + } + + // Set the user context explicitly so the match never inherits the request user + // of whatever invoked the plugin: a username scopes to that user (loading their + // annotations and library access), and an unscoped match runs as admin so only + // the plugin's own library scope constrains the results. + scoped := opts.Username != "" + if scoped { + usr, err := s.users.resolve(ctx, s.ds, opts.Username) + if err != nil { + return nil, fmt.Errorf("matcher: %w", err) + } + ctx = request.WithUser(ctx, *usr) + } else { + ctx = adminContext(ctx) + } + + agentSongs := slice.Map(songs, songRefToAgentSong) + + matched, err := matcher.New(s.ds).MatchSongsIndexed(ctx, agentSongs) + if err != nil { + return nil, err + } + // The plugin's library scope is a second, independent authorization on top of the context + // user's own access, so it's applied here rather than in-query: the unscoped path runs as + // admin (which applyLibraryFilter skips), and folding s.libs into the context user would + // conflate the two scopes instead of intersecting them. + for i, mf := range matched { + // Drop tracks outside the plugin's library scope, leaving that index unmatched. + if !s.libs.contains(mf.LibraryID) { + continue + } + results[i] = s.toTrack(&mf, scoped) + } + return results, nil +} + +// toTrack projects a MediaFile into the public Track DTO. Path needs filesystem +// permission; per-user annotations are only set for a scoped match. +func (s *matcherServiceImpl) toTrack(mf *model.MediaFile, scoped bool) *types.Track { + t := &types.Track{ + ID: mf.ID, + LibraryID: int32(mf.LibraryID), + LibraryName: mf.LibraryName, + Missing: mf.Missing, + Title: mf.Title, + Album: mf.Album, + Artist: mf.Artist, + AlbumArtist: mf.AlbumArtist, + AlbumID: mf.AlbumID, + SortTitle: mf.SortTitle, + SortAlbumName: mf.SortAlbumName, + SortArtistName: mf.SortArtistName, + TrackNumber: int32(mf.TrackNumber), + DiscNumber: int32(mf.DiscNumber), + DiscSubtitle: mf.DiscSubtitle, + Year: int32(mf.Year), + Date: mf.Date, + OriginalYear: int32(mf.OriginalYear), + OriginalDate: mf.OriginalDate, + ReleaseYear: int32(mf.ReleaseYear), + ReleaseDate: mf.ReleaseDate, + Size: mf.Size, + Suffix: mf.Suffix, + Duration: float64(mf.Duration), + BitRate: int32(mf.BitRate), + SampleRate: int32(mf.SampleRate), + BitDepth: ptrInt32(mf.BitDepth), + Channels: int32(mf.Channels), + Codec: mf.Codec, + Comment: mf.Comment, + BPM: ptrInt32(mf.BPM), + ExplicitStatus: mf.ExplicitStatus, + CatalogNum: mf.CatalogNum, + Compilation: mf.Compilation, + HasCoverArt: mf.HasCoverArt, + MbzRecordingID: mf.MbzRecordingID, + MbzReleaseTrackID: mf.MbzReleaseTrackID, + MbzAlbumID: mf.MbzAlbumID, + MbzReleaseGroupID: mf.MbzReleaseGroupID, + MbzAlbumType: mf.MbzAlbumType, + MbzAlbumComment: mf.MbzAlbumComment, + RGAlbumGain: mf.RGAlbumGain, + RGAlbumPeak: mf.RGAlbumPeak, + RGTrackGain: mf.RGTrackGain, + RGTrackPeak: mf.RGTrackPeak, + AverageRating: mf.AverageRating, // aggregate, not user-scoped + BirthTime: unixOrZero(mf.BirthTime), + CreatedAt: unixOrZero(mf.CreatedAt), + UpdatedAt: unixOrZero(mf.UpdatedAt), + } + if s.hasFilesystemPerm { + t.Path = mf.Path + } + if len(mf.Genres) > 0 { + t.Genres = slice.Map(mf.Genres, func(g model.Genre) string { return g.Name }) + } + if len(mf.Tags) > 0 { + t.Tags = make(map[string][]string, len(mf.Tags)) + for name, values := range mf.Tags { + t.Tags[string(name)] = values + } + } + if len(mf.Participants) > 0 { + // Flatten the role→artists map into a role-tagged list, in stable role order. + roles := slices.SortedFunc(maps.Keys(mf.Participants), func(a, b model.Role) int { + return cmp.Compare(a.String(), b.String()) + }) + for _, role := range roles { + for _, p := range mf.Participants[role] { + t.Participants = append(t.Participants, types.ArtistRef{ + ID: p.ID, + Name: p.Name, + MBID: p.MbzArtistID, + SortName: p.SortArtistName, + Role: role.String(), + SubRole: p.SubRole, + }) + } + } + } + if scoped { + t.Starred = mf.Starred + t.StarredAt = unixPtr(mf.StarredAt) + t.Rating = int32(mf.Rating) + t.PlayCount = mf.PlayCount + t.PlayDate = unixPtr(mf.PlayDate) + } + return t +} + +func unixOrZero(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +// unixPtr maps a nullable time to Unix seconds, keeping nil distinct from the epoch. +func unixPtr(t *time.Time) *int64 { + if t == nil || t.IsZero() { + return nil + } + return new(t.Unix()) +} + +// ptrInt32 narrows a nullable *int to *int32, keeping nil distinct from a real 0. +func ptrInt32(p *int) *int32 { + if p == nil { + return nil + } + return new(int32(*p)) +} + +var _ host.MatcherService = (*matcherServiceImpl)(nil) diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go new file mode 100644 index 000000000..9d44ee581 --- /dev/null +++ b/plugins/host_matcher_test.go @@ -0,0 +1,490 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/plugins/types" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MatcherService", Ordered, func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + // newConverter returns the service as its concrete type so converter unit + // tests can call toTrack directly with a chosen filesystem-permission flag. + newConverter := func(hasFilesystemPerm bool) *matcherServiceImpl { + return newMatcherService(nil, hasFilesystemPerm, newUserAccess(nil, true), newLibraryAccess(nil, true)).(*matcherServiceImpl) + } + + Describe("toTrack", func() { + It("projects a MediaFile into a public Track", func() { + bitDepth := 24 + bpm := 128 + rgGain := -7.5 + created := time.Unix(1700000000, 0) + updated := time.Unix(1700000500, 0) + birth := time.Unix(1699999000, 0) + + mf := &model.MediaFile{ + ID: "mf-1", + LibraryID: 3, + LibraryName: "Main", + Path: "/music/song.flac", + Title: "My Song", + Album: "My Album", + Artist: "My Artist", + AlbumArtist: "My Artist", + AlbumID: "al-1", + SortTitle: "my song", + TrackNumber: 4, + DiscNumber: 1, + Year: 2020, + Size: 1234, + Suffix: "flac", + Duration: 210.5, + BitRate: 1000, + SampleRate: 44100, + BitDepth: &bitDepth, + Channels: 2, + Codec: "flac", + Genre: "Rock", + BPM: &bpm, + ExplicitStatus: "c", + Compilation: true, + HasCoverArt: true, + MbzRecordingID: "rec-1", + RGTrackGain: &rgGain, + CreatedAt: created, + UpdatedAt: updated, + BirthTime: birth, + Genres: model.Genres{{Name: "Rock"}, {Name: "Pop"}}, + Tags: model.Tags{model.TagName("isrc"): []string{"US-XXX-00"}}, + } + mf.AverageRating = 4.2 + mf.Participants = model.Participants{} + mf.Participants.Add(model.RoleArtist, model.Artist{ + ID: "ar-1", Name: "My Artist", SortArtistName: "artist, my", MbzArtistID: "mbz-ar-1", + }) + mf.Participants.AddWithSubRole(model.RolePerformer, "violin", model.Artist{ + ID: "ar-2", Name: "A Fiddler", + }) + + track := newConverter(true).toTrack(mf, false) + + Expect(track.ID).To(Equal("mf-1")) + Expect(track.LibraryID).To(Equal(int32(3))) + Expect(track.LibraryName).To(Equal("Main")) + Expect(track.Path).To(Equal("/music/song.flac")) + Expect(track.Title).To(Equal("My Song")) + Expect(track.Duration).To(Equal(210.5)) + Expect(track.BitDepth).To(HaveValue(Equal(int32(24)))) + Expect(track.BPM).To(HaveValue(Equal(int32(128)))) + Expect(track.RGTrackGain).To(HaveValue(Equal(-7.5))) + Expect(track.Compilation).To(BeTrue()) + Expect(track.MbzRecordingID).To(Equal("rec-1")) + Expect(track.Genres).To(Equal([]string{"Rock", "Pop"})) + Expect(track.CreatedAt).To(Equal(int64(1700000000))) + Expect(track.UpdatedAt).To(Equal(int64(1700000500))) + Expect(track.BirthTime).To(Equal(int64(1699999000))) + Expect(track.Tags).To(HaveKeyWithValue("isrc", []string{"US-XXX-00"})) + // Flat, role-tagged, role-sorted. + Expect(track.Participants).To(HaveLen(2)) + Expect(track.Participants[0]).To(Equal(types.ArtistRef{ + ID: "ar-1", Name: "My Artist", SortName: "artist, my", MBID: "mbz-ar-1", Role: "artist", + })) + Expect(track.Participants[1]).To(Equal(types.ArtistRef{ + ID: "ar-2", Name: "A Fiddler", Role: "performer", SubRole: "violin", + })) + // AverageRating is an aggregate, exposed even though the match is unscoped. + Expect(track.AverageRating).To(Equal(4.2)) + }) + + It("leaves nil-able numeric fields nil when absent", func() { + mf := &model.MediaFile{ID: "mf-2", Title: "No Optionals"} + track := newConverter(true).toTrack(mf, false) + Expect(track.BitDepth).To(BeNil()) + Expect(track.BPM).To(BeNil()) + Expect(track.RGAlbumGain).To(BeNil()) + Expect(track.RGAlbumPeak).To(BeNil()) + Expect(track.RGTrackGain).To(BeNil()) + Expect(track.RGTrackPeak).To(BeNil()) + }) + + It("preserves a real 0 ReplayGain value as non-nil", func() { + zero := 0.0 + mf := &model.MediaFile{ID: "mf-3", Title: "Zero RG", RGTrackGain: &zero} + track := newConverter(true).toTrack(mf, false) + Expect(track.RGTrackGain).To(HaveValue(Equal(0.0))) + Expect(track.RGAlbumGain).To(BeNil()) + }) + + It("exposes Path only when the plugin has filesystem permission", func() { + mf := &model.MediaFile{ID: "mf-4", Title: "With Path", Path: "/music/x.flac"} + Expect(newConverter(true).toTrack(mf, false).Path).To(Equal("/music/x.flac")) + Expect(newConverter(false).toTrack(mf, false).Path).To(BeEmpty()) + }) + }) + + Describe("MatchSongs", func() { + // The mock MediaFileRepo returns stored files (with annotations) verbatim, + // ignoring QueryOptions and the context user. These tests therefore cover the + // adapter's gating/access logic, not the SQL per-user join (a persistence-layer + // concern). + + // allowAll returns a service permitted to match as any user across all + // libraries. + allowAll := func(ds model.DataStore) host.MatcherService { + return newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + } + + It("returns one entry per input song in order, with nil for no-match", func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + // First (ID) phase returns the match for input song 0 only. + mediaFileRepo.SetData(model.MediaFiles{ + {ID: "mf-100", Title: "Hit", Artist: "Band"}, + }) + ds := &tests.MockDataStore{MockedMediaFile: mediaFileRepo} + + results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), []types.SongRef{ + {ID: "mf-100", Name: "Hit", Artist: "Band"}, + {ID: "missing-id", Name: "Ghost", Artist: "Nobody"}, + }, host.MatchOptions{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].ID).To(Equal("mf-100")) + Expect(results[1]).To(BeNil()) + }) + + It("returns an empty slice for empty input", func() { + ds := &tests.MockDataStore{MockedMediaFile: tests.CreateMockMediaFileRepo()} + results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), nil, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + Context("with a scoped user", func() { + var ds *tests.MockDataStore + var userRepo *tests.MockedUserRepo + + BeforeEach(func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + mf := model.MediaFile{ID: "mf-1", Title: "Hit", Artist: "Band", LibraryID: 1} + mf.Starred = true + mf.Rating = 5 + mediaFileRepo.SetData(model.MediaFiles{mf}) + + userRepo = tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + + ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo, MockedUser: userRepo} + }) + + input := []types.SongRef{{ID: "mf-1", Name: "Hit", Artist: "Band"}} + + It("does not expose annotations when no username is given", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].Starred).To(BeFalse()) + Expect(results[0].Rating).To(BeZero()) + }) + + It("exposes the user's annotations when an allowed username is given", func() { + svc := newMatcherService(ds, false, newUserAccess([]string{"u-alice"}, false), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].Starred).To(BeTrue()) + Expect(results[0].Rating).To(Equal(int32(5))) + }) + + It("allows any username when allUsers is set", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0].Starred).To(BeTrue()) + }) + + It("returns an error for an unknown username", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) + Expect(err).To(MatchError(ContainSubstring("not found"))) + }) + + It("surfaces a backend error rather than masking it as not-found", func() { + userRepo.Error = errors.New("db is locked") + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).To(MatchError(ContainSubstring("db is locked"))) + Expect(err.Error()).ToNot(ContainSubstring("not found")) + }) + + It("returns an error for a username the plugin is not allowed to use", func() { + svc := newMatcherService(ds, false, newUserAccess([]string{"u-bob"}, false), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).To(MatchError(ContainSubstring("not allowed"))) + }) + + It("rejects a username with the same error whether it exists, when the plugin has no user scope", func() { + // A plugin with no user scope (the only state a matcher-only plugin can + // be in) must not leak whether a username exists via the error text. + svc := newMatcherService(ds, false, newUserAccess(nil, false), newLibraryAccess(nil, true)) + + _, errExisting := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + _, errMissing := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) + + Expect(errExisting).To(HaveOccurred()) + Expect(errExisting.Error()).To(Equal(errMissing.Error())) + Expect(errExisting.Error()).ToNot(ContainSubstring("not found")) + Expect(errExisting.Error()).To(ContainSubstring("not authorized to scope by user")) + }) + + It("does not inherit the caller's request user for an unscoped match", func() { + // The plugin may be invoked while handling another user's request; an + // unscoped match must run as admin, not as that inherited user. + capturing := &ctxCapturingDataStore{MockDataStore: ds} + svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + + callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) + _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + + usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + Expect(ok).To(BeTrue()) + Expect(usr.IsAdmin).To(BeTrue()) + Expect(usr.ID).ToNot(Equal("u-caller")) + }) + + It("uses the requested user, overriding an inherited caller user", func() { + capturing := &ctxCapturingDataStore{MockDataStore: ds} + svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + + callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) + _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + + usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + Expect(ok).To(BeTrue()) + Expect(usr.ID).To(Equal("u-alice")) + }) + }) + + Context("with plugin library access", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + mediaFileRepo.SetData(model.MediaFiles{ + {ID: "mf-lib1", Title: "A", Artist: "Band", LibraryID: 1}, + {ID: "mf-lib2", Title: "B", Artist: "Band", LibraryID: 2}, + }) + ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo} + }) + + input := []types.SongRef{ + {ID: "mf-lib1", Name: "A", Artist: "Band"}, + {ID: "mf-lib2", Name: "B", Artist: "Band"}, + } + + It("drops matches from libraries the plugin cannot access", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess([]int{1}, false)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].ID).To(Equal("mf-lib1")) + Expect(results[1]).To(BeNil()) // library 2 not permitted + }) + + It("keeps all matches when allLibraries is set", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[1]).ToNot(BeNil()) + }) + + It("errors when the plugin has no library scope configured", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, false)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).To(MatchError(ContainSubstring("no libraries configured"))) + }) + }) + }) + + // Artist precedence for songRefToAgentSong is covered in metadata_agent_test.go; + // here we cover the duration normalization the matcher path relies on. + Describe("songRefToAgentSong duration", func() { + It("prefers DurationMs over the deprecated seconds field", func() { + song := songRefToAgentSong(types.SongRef{DurationMs: 247333, Duration: 99}) + Expect(song.Duration).To(Equal(uint32(247333))) + }) + + It("falls back to the seconds field when DurationMs is zero", func() { + song := songRefToAgentSong(types.SongRef{Duration: 210.5}) + Expect(song.Duration).To(Equal(uint32(210500))) + }) + + It("clamps a negative seconds duration to zero instead of overflowing", func() { + song := songRefToAgentSong(types.SongRef{Duration: -1}) + Expect(song.Duration).To(BeZero()) + }) + }) +}) + +var _ = Describe("MatcherService Integration", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "matcher-integration-test-*") + Expect(err).ToNot(HaveOccurred()) + + srcPath := filepath.Join(testdataDir, "test-matcher"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-matcher"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + // AllLibraries: the matcher requires a library scope. + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-matcher", + Path: destPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, + AllLibraries: true, + }}) + + mediaFileRepo := tests.CreateMockMediaFileRepo() + hit := model.MediaFile{ID: "mf-hit", Title: "Hit", Artist: "Band"} + hit.Starred = true + mediaFileRepo.SetData(model.MediaFiles{hit}) + + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + + dataStore := &tests.MockDataStore{ + MockedPlugin: mockPluginRepo, + MockedMediaFile: mediaFileRepo, + MockedUser: userRepo, + } + + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + subsonicRouter: http.NotFoundHandler(), + } + Expect(manager.Start(GinkgoT().Context())).To(Succeed()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + It("loads the plugin with the matcher permission", func() { + manager.mu.RLock() + p, ok := manager.plugins["test-matcher"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + Expect(p.manifest.Permissions).ToNot(BeNil()) + Expect(p.manifest.Permissions.Matcher).ToNot(BeNil()) + }) + + It("matches songs through the host boundary, preserving order and nils", func() { + ctx := GinkgoT().Context() + manager.mu.RLock() + p := manager.plugins["test-matcher"] + manager.mu.RUnlock() + + instance, err := p.instance(ctx) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(ctx) + + type tIn struct { + Songs []types.SongRef `json:"songs"` + Username string `json:"username,omitempty"` + } + type tOut struct { + MatchedIDs []string `json:"matched_ids"` + Starred []bool `json:"starred"` + Error *string `json:"error,omitempty"` + } + + call := func(in tIn) tOut { + inputBytes, err := json.Marshal(in) + Expect(err).ToNot(HaveOccurred()) + _, outputBytes, err := instance.Call("nd_test_matcher", inputBytes) + Expect(err).ToNot(HaveOccurred()) + var out tOut + Expect(json.Unmarshal(outputBytes, &out)).To(Succeed()) + Expect(out.Error).To(BeNil()) + return out + } + + songs := []types.SongRef{ + {ID: "mf-hit", Name: "Hit", Artist: "Band"}, + {ID: "nope", Name: "Ghost", Artist: "Nobody"}, + } + + By("matching without a user, preserving order and nils") + out := call(tIn{Songs: songs}) + Expect(out.MatchedIDs).To(HaveLen(2)) + Expect(out.MatchedIDs[0]).To(Equal("mf-hit")) + Expect(out.MatchedIDs[1]).To(BeEmpty()) + Expect(out.Starred[0]).To(BeFalse()) // no user scope → no annotations + + By("matching as a user, exposing that user's annotations across the boundary") + scoped := call(tIn{Songs: songs, Username: "alice"}) + Expect(scoped.MatchedIDs[0]).To(Equal("mf-hit")) + Expect(scoped.Starred[0]).To(BeTrue()) + }) +}) + +// ctxCapturingDataStore records the context passed to MediaFile so tests can assert +// which user the matcher resolved before querying the library. +type ctxCapturingDataStore struct { + *tests.MockDataStore + lastMediaFileCtx context.Context +} + +func (d *ctxCapturingDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + d.lastMediaFileCtx = ctx + return d.MockDataStore.MediaFile(ctx) +} diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index 01a33c039..dba58d795 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1" // URL Format: Only the path and query parameters are used - host/protocol are ignored. // Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format). type subsonicAPIServiceImpl struct { - pluginID string - router SubsonicRouter - ds model.DataStore - allowedUserIDs []string // User IDs this plugin can access (from DB configuration) - allUsers bool // If true, plugin can access all users - userIDMap map[string]struct{} + pluginID string + router SubsonicRouter + ds model.DataStore + users userAccess // users this plugin may act as (from DB configuration) } // newSubsonicAPIService creates a new SubsonicAPIService for a plugin. -func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService { - userIDMap := make(map[string]struct{}) - for _, id := range allowedUserIDs { - userIDMap[id] = struct{}{} - } +func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, users userAccess) host.SubsonicAPIService { return &subsonicAPIServiceImpl{ - pluginID: pluginID, - router: router, - ds: ds, - allowedUserIDs: allowedUserIDs, - allUsers: allUsers, - userIDMap: userIDMap, + pluginID: pluginID, + router: router, + ds: ds, + users: users, } } @@ -136,12 +128,12 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { // If allUsers is true, allow any user - if s.allUsers { + if s.users.allUsers { return nil } // Must have at least one allowed user ID configured - if len(s.allowedUserIDs) == 0 { + if len(s.users.userIDSet) == 0 { return fmt.Errorf("no users configured for plugin %s", s.pluginID) } @@ -155,7 +147,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username } // Check if the user's ID is in the allowed list - if _, ok := s.userIDMap[usr.ID]; !ok { + if !s.users.allows(usr.ID) { return fmt.Errorf("user %s is not authorized for this plugin", username) } diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 6f7ff4dd3..c3d9ffe8a 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -267,7 +267,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with specific user IDs allowed", func() { It("blocks users not in the allowed list", func() { // allowedUserIDs contains "user2", but testuser is "user1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -277,7 +277,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows users in the allowed list", func() { // allowedUserIDs contains "user2" which is "alloweduser" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=alloweduser") @@ -287,7 +287,7 @@ var _ = Describe("SubsonicAPIService", func() { It("blocks admin users when not in allowed list", func() { // allowedUserIDs only contains "user1" (testuser), not "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=adminuser") @@ -297,7 +297,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows admin users when in allowed list", func() { // allowedUserIDs contains "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"admin1"}, false)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -308,7 +308,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with allUsers=true", func() { It("allows all users regardless of allowed list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=testuser") @@ -317,7 +317,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("allows admin users when allUsers is true", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -328,7 +328,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with no users configured", func() { It("returns error when no users are configured", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -337,7 +337,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for empty user list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -349,7 +349,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("URL Handling", func() { It("returns error for missing username parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping") @@ -358,7 +358,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "://invalid") @@ -367,7 +367,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("extracts endpoint from path correctly", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/rest/ping.view?u=testuser") @@ -380,7 +380,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("CallRaw", func() { It("returns binary data and content-type", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -390,7 +390,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("does not set f=json parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -402,7 +402,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("enforces permission checks", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -411,7 +411,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when username is missing", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt") @@ -420,7 +420,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") @@ -429,7 +429,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "://invalid") @@ -440,7 +440,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("Router Availability", func() { It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index c66b027d7..12d84f60d 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -31,8 +31,8 @@ type LyricsPlugin struct { plugin *plugin } -// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses -// using model.ToLyrics. +// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response +// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain). func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { req := capabilities.GetLyricsRequest{ Track: mediaFileToTrackInfo(l.plugin, mf), @@ -50,13 +50,15 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode if lang == "" { lang = "xxx" } - parsed, err := model.ToLyrics(lang, lt.Text) + parsed, err := model.ParseLyrics("", lang, []byte(lt.Text)) if err != nil { log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err) continue } - if parsed != nil && !parsed.IsEmpty() { - result = append(result, *parsed) + for _, lyric := range parsed { + if !lyric.IsEmpty() { + result = append(result, lyric) + } } } return result, nil diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index a1a6c1809..6e82dbfab 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -83,6 +83,32 @@ var _ = Describe("LyricsPlugin", Ordered, func() { _, err := p.GetLyrics(GinkgoT().Context(), track) Expect(err).To(HaveOccurred()) }) + + // Each DescribeTable entry proves that the adapter's content-sniffing routes + // the plugin's rich payload to the right parser rather than mangling it as plain text. + DescribeTable("content-sniffs plugin responses across all supported formats", + func(format string, wantSynced bool, wantLine string) { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": {"format": format}, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + result, err := p.GetLyrics(GinkgoT().Context(), track) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Synced).To(Equal(wantSynced), "unexpected Synced value for format %s", format) + Expect(result[0].Line).To(HaveLen(1)) + Expect(result[0].Line[0].Value).To(Equal(wantLine)) + }, + Entry("ttml", "ttml", true, "plugin ttml line"), + Entry("srt", "srt", true, "plugin srt line"), + Entry("yaml", "yaml", true, "plugin yaml line"), + Entry("lrc", "lrc", true, "plugin lrc line"), + Entry("plain", "plain", false, "plugin plain line"), + ) }) Describe("PluginNames", func() { diff --git a/plugins/manager.go b/plugins/manager.go index 67e0ee987..b3a71ce6c 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -241,7 +241,7 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) } -func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { +func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, bool) { return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin) } @@ -382,7 +382,7 @@ func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON strin return fmt.Errorf("getting plugin from DB: %w", err) } - manifest, err := readManifest(plugin.Path) + manifest, err := ReadManifest(plugin.Path) if err != nil { return fmt.Errorf("reading manifest: %w", err) } @@ -460,7 +460,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn shouldDisable := false disableReason := "" if wasEnabled { - manifest, err := readManifest(plugin.Path) + manifest, err := ReadManifest(plugin.Path) if err == nil && manifest.Permissions != nil { if manifest.Permissions.Users != nil && !hasValidUsersConfig(plugin.Users, plugin.AllUsers) { shouldDisable = true @@ -591,7 +591,7 @@ func (m *Manager) UnloadDisabledPlugins(ctx context.Context) { // before a plugin can be enabled. Returns an error if any gate condition fails. func (m *Manager) checkPermissionGates(p *model.Plugin) error { // Parse manifest to check permissions - manifest, err := readManifest(p.Path) + manifest, err := ReadManifest(p.Path) if err != nil { return fmt.Errorf("reading manifest: %w", err) } diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index ccda9e4cb..604fba3a7 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -52,7 +52,7 @@ var hostServices = []hostServiceEntry{ name: "SubsonicAPI", hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil }, create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { - service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) + service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) return host.RegisterSubsonicAPIHostFunctions(service), nil }, }, @@ -119,6 +119,19 @@ var hostServices = []hostServiceEntry{ return host.RegisterUsersHostFunctions(service), nil }, }, + { + name: "Matcher", + hasPermission: func(p *Permissions) bool { return p != nil && p.Matcher != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + hasFilesystemPerm := ctx.permissions.Library != nil && ctx.permissions.Library.Filesystem + service := newMatcherService( + ctx.manager.ds, hasFilesystemPerm, + newUserAccess(ctx.allowedUsers, ctx.allUsers), + newLibraryAccess(ctx.allowedLibraries, ctx.allLibraries), + ) + return host.RegisterMatcherHostFunctions(service), nil + }, + }, { name: "HTTP", hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil }, @@ -155,12 +168,12 @@ func (m *Manager) extractManifest(ndpPath string) (*PluginMetadata, error) { return nil, fmt.Errorf("manager is stopped") } - manifest, err := readManifest(ndpPath) + manifest, err := ReadManifest(ndpPath) if err != nil { return nil, err } - sha256Hash, err := computeFileSHA256(ndpPath) + sha256Hash, err := ComputeFileSHA256(ndpPath) if err != nil { return nil, fmt.Errorf("computing hash: %w", err) } diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 1d4a8c301..f0c7c56d5 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -4,9 +4,11 @@ import ( "context" "crypto/rand" "errors" + "fmt" "io" extism "github.com/extism/go-sdk" + "github.com/navidrome/navidrome/model" "github.com/tetratelabs/wazero" ) @@ -75,3 +77,58 @@ func (a libraryAccess) contains(libID int) bool { _, ok := a.libraryIDSet[libID] return ok } + +// configured reports whether the plugin has any library scope (all, or specific). +func (a libraryAccess) configured() bool { + return a.allLibraries || len(a.libraryIDSet) > 0 +} + +// userAccess captures the set of users a plugin is permitted to act as, +// precomputed at load time for O(1) lookup. +type userAccess struct { + allUsers bool + userIDSet map[string]struct{} +} + +func newUserAccess(allowedUserIDs []string, allUsers bool) userAccess { + set := make(map[string]struct{}, len(allowedUserIDs)) + for _, id := range allowedUserIDs { + set[id] = struct{}{} + } + return userAccess{allUsers: allUsers, userIDSet: set} +} + +// allows reports whether the plugin may act as the given user ID. +func (a userAccess) allows(userID string) bool { + if a.allUsers { + return true + } + _, ok := a.userIDSet[userID] + return ok +} + +// resolve looks up a user by username and authorizes it against this access set, +// distinguishing an absent user from a backend failure. +// +// When the plugin has no user scope at all, it rejects before the lookup with a +// single fixed error, so a caller cannot tell a real account from a missing one by +// the error text (username enumeration). +func (a userAccess) resolve(ctx context.Context, ds model.DataStore, username string) (*model.User, error) { + if !a.allUsers && len(a.userIDSet) == 0 { + return nil, fmt.Errorf("plugin is not authorized to scope by user") + } + usr, err := ds.User(ctx).FindByUsername(username) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return nil, fmt.Errorf("user %q not found", username) + } + return nil, fmt.Errorf("looking up user %q: %w", username, err) + } + if usr == nil { // defensive: a conforming repo returns ErrNotFound, not (nil, nil) + return nil, fmt.Errorf("user %q not found", username) + } + if !a.allows(usr.ID) { + return nil, fmt.Errorf("plugin is not allowed to act as user %q", username) + } + return usr, nil +} diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index f97069e74..23d904309 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -36,9 +36,9 @@ func marshalManifest(m *Manifest) string { return string(b) } -// computeFileSHA256 computes the SHA-256 hash of a file without loading it into memory. +// ComputeFileSHA256 computes the SHA-256 hash of a file without loading it into memory. // This is used for quick change detection before full plugin compilation. -func computeFileSHA256(path string) (string, error) { +func ComputeFileSHA256(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err @@ -165,7 +165,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { dbPlugin, exists := pluginsInDB[name] // Compute SHA256 first (lightweight operation) to check if plugin changed - sha256Hash, err := computeFileSHA256(path) + sha256Hash, err := ComputeFileSHA256(path) if err != nil { log.Error(ctx, "Failed to compute SHA256 for plugin", "plugin", name, "path", path, err) continue diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go new file mode 100644 index 000000000..e2adebfad --- /dev/null +++ b/plugins/manager_sync_test.go @@ -0,0 +1,30 @@ +package plugins + +import ( + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ComputeFileSHA256", func() { + It("returns a consistent 64-char lowercase hex hash for the same file", func() { + dir := GinkgoT().TempDir() + ndpPath := filepath.Join(dir, "test.ndp") + err := createTestPackage(ndpPath, &Manifest{Name: "S", Author: "a", Version: "1.0.0"}, []byte{0x00, 0x61, 0x73, 0x6d}) + Expect(err).ToNot(HaveOccurred()) + + hash1, err := ComputeFileSHA256(ndpPath) + Expect(err).ToNot(HaveOccurred()) + hash2, err := ComputeFileSHA256(ndpPath) + Expect(err).ToNot(HaveOccurred()) + + Expect(hash1).To(Equal(hash2)) + Expect(hash1).To(MatchRegexp(`^[0-9a-f]{64}$`)) + }) + + It("returns an error for a non-existent path", func() { + _, err := ComputeFileSHA256(filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp")) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index b7022b46e..d666d8620 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -158,7 +158,7 @@ func (m *Manager) processPluginEvent(pluginName string) { switch action { case actionUpdate: // File changed - check SHA256 first, then extract manifest if needed - sha256Hash, err := computeFileSHA256(ndpPath) + sha256Hash, err := ComputeFileSHA256(ndpPath) if err != nil { log.Error(m.ctx, "Failed to compute SHA256 for changed plugin", "plugin", pluginName, err) return diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index c15a3bf3d..29e5d1fc7 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -113,6 +113,9 @@ }, "taskqueue": { "$ref": "#/$defs/TaskQueuePermission" + }, + "matcher": { + "$ref": "#/$defs/MatcherPermission" } } }, @@ -254,6 +257,17 @@ "description": "Explanation for why users access is needed" } } + }, + "MatcherPermission": { + "type": "object", + "description": "Matcher service permissions for resolving external songs to local library tracks", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why matcher access is needed" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index 7484718e3..6bd0e8049 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -3,10 +3,37 @@ package plugins import ( "encoding/json" "fmt" + "reflect" + "sort" + "strings" "github.com/santhosh-tekuri/jsonschema/v6" ) +// DeclaredNames returns the sorted names of the non-nil permission fields. It +// reflects over the generated json tags so new permission types are picked up +// automatically rather than via a hand-maintained list. +func (p *Permissions) DeclaredNames() []string { + if p == nil { + return nil + } + var names []string + v := reflect.ValueOf(*p) + t := v.Type() + for i := 0; i < t.NumField(); i++ { + f := v.Field(i) + if f.Kind() != reflect.Pointer || f.IsNil() { + continue + } + tag := t.Field(i).Tag.Get("json") + if name, _, _ := strings.Cut(tag, ","); name != "" && name != "-" { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + //go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json // ParseManifest unmarshals manifest JSON and performs cross-field validation. @@ -32,6 +59,14 @@ func (m *Manifest) Validate() error { } } + // Matcher returns library content, so it requires the library permission (which + // is what exposes a library scope for configuration). + if m.Permissions != nil && m.Permissions.Matcher != nil { + if m.Permissions.Library == nil { + return fmt.Errorf("'matcher' permission requires 'library' permission to be declared") + } + } + // Validate config schema if present if m.Config != nil && m.Config.Schema != nil { if err := validateConfigSchema(m.Config.Schema); err != nil { diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index efe93e05f..3599eafc4 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -158,6 +158,12 @@ func (j *Manifest) UnmarshalJSON(value []byte) error { return nil } +// Matcher service permissions for resolving external songs to local library tracks +type MatcherPermission struct { + // Explanation for why matcher access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Permissions required by the plugin type Permissions struct { // Artwork corresponds to the JSON schema field "artwork". @@ -175,6 +181,9 @@ type Permissions struct { // Library corresponds to the JSON schema field "library". Library *LibraryPermission `json:"library,omitempty" yaml:"library,omitempty" mapstructure:"library,omitempty"` + // Matcher corresponds to the JSON schema field "matcher". + Matcher *MatcherPermission `json:"matcher,omitempty" yaml:"matcher,omitempty" mapstructure:"matcher,omitempty"` + // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 0d371d14a..2a8b0dcfa 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -261,6 +261,37 @@ var _ = Describe("Manifest", func() { Expect(err.Error()).To(ContainSubstring("subsonicapi")) }) + It("validates manifest with matcher and library permissions", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Matcher: &MatcherPermission{}, + Library: &LibraryPermission{}, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when matcher without library permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Matcher: &MatcherPermission{}, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("matcher")) + Expect(err.Error()).To(ContainSubstring("library")) + }) + It("validates manifest without subsonicapi", func() { m := &Manifest{ Name: "Test", @@ -464,3 +495,27 @@ var _ = Describe("Manifest", func() { }) }) }) + +var _ = Describe("Permissions.DeclaredNames", func() { + It("returns nil for a nil receiver", func() { + var p *Permissions + Expect(p.DeclaredNames()).To(BeEmpty()) + }) + + It("returns declared names sorted", func() { + p := &Permissions{ + Subsonicapi: &SubsonicAPIPermission{}, + Users: &UsersPermission{}, + } + Expect(p.DeclaredNames()).To(Equal([]string{"subsonicapi", "users"})) + }) + + It("returns all declared names sorted regardless of field order", func() { + p := &Permissions{ + Http: &HTTPPermission{}, + Artwork: &ArtworkPermission{}, + Cache: &CachePermission{}, + } + Expect(p.DeclaredNames()).To(Equal([]string{"artwork", "cache", "http"})) + }) +}) diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 52542300c..e5d6d43fc 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" "github.com/navidrome/navidrome/utils/slice" ) @@ -227,23 +228,34 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)}) } -// songRefToAgentSong converts a single SongRef to agents.Song -func songRefToAgentSong(s capabilities.SongRef) agents.Song { +// songRefToAgentSong converts a single SongRef to agents.Song. SongRef keeps the single +// Artist/ArtistMBID fields as part of the plugin wire contract; when a plugin sends those instead +// of the artists array, they are folded into a one-element Artists list here. +func songRefToAgentSong(s types.SongRef) agents.Song { + var artists []agents.Artist + switch { + case len(s.Artists) > 0: + artists = make([]agents.Artist, len(s.Artists)) + for i, a := range s.Artists { + artists[i] = agents.Artist{ID: a.ID, Name: a.Name, MBID: a.MBID} + } + case s.Artist != "" || s.ArtistMBID != "": + artists = []agents.Artist{{Name: s.Artist, MBID: s.ArtistMBID}} + } return agents.Song{ - ID: s.ID, - Name: s.Name, - MBID: s.MBID, - ISRC: s.ISRC, - Artist: s.Artist, - ArtistMBID: s.ArtistMBID, - Album: s.Album, - AlbumMBID: s.AlbumMBID, - Duration: uint32(s.Duration * 1000), + ID: s.ID, + Name: s.Name, + MBID: s.MBID, + ISRC: s.ISRC, + Artists: artists, + Album: s.Album, + AlbumMBID: s.AlbumMBID, + Duration: s.DurationInMs(), } } // songRefsToAgentSongs converts a slice of SongRef to agents.Song -func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { +func songRefsToAgentSongs(refs []types.SongRef) []agents.Song { return slice.Map(refs, songRefToAgentSong) } diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 067ae80ca..a14db5d34 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -4,6 +4,7 @@ package plugins import ( "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -118,7 +119,8 @@ var _ = Describe("MetadataAgent", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(3)) Expect(songs[0].Name).To(Equal("Similar to Yesterday #1")) - Expect(songs[0].Artist).To(Equal("The Beatles")) + Expect(songs[0].Artists).To(HaveLen(1)) + Expect(songs[0].Artists[0].Name).To(Equal("The Beatles")) }) }) @@ -329,3 +331,31 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() { Expect(err).To(MatchError(errNotImplemented)) }) }) + +var _ = Describe("songRefToAgentSong multi-artist", func() { + It("maps ArtistRef to agents.Artist", func() { + ref := capabilities.SongRef{Name: "Collab", Artist: "Drake", Artists: []capabilities.ArtistRef{ + {ID: "id-drake", Name: "Drake", MBID: "m-drake"}, + {Name: "Future", MBID: "m-future"}, + }} + got := songRefToAgentSong(ref) + Expect(got.Artists).To(Equal([]agents.Artist{ + {ID: "id-drake", Name: "Drake", MBID: "m-drake"}, + {Name: "Future", MBID: "m-future"}, + })) + }) + It("folds the single Artist/ArtistMBID into a one-element Artists when no Artists provided", func() { + ref := capabilities.SongRef{Name: "Solo", Artist: "Drake", ArtistMBID: "m-drake"} + got := songRefToAgentSong(ref) + Expect(got.Artists).To(Equal([]agents.Artist{{Name: "Drake", MBID: "m-drake"}})) + }) + It("folds an MBID-only single artist (empty name) so the MBID is not dropped", func() { + ref := capabilities.SongRef{Name: "Solo", ArtistMBID: "m-drake"} + got := songRefToAgentSong(ref) + Expect(got.Artists).To(Equal([]agents.Artist{{MBID: "m-drake"}})) + }) + It("leaves Artists nil when neither Artists nor the single Artist/ArtistMBID are provided", func() { + got := songRefToAgentSong(capabilities.SongRef{Name: "Anon"}) + Expect(got.Artists).To(BeNil()) + }) +}) diff --git a/plugins/package.go b/plugins/package.go index 475761231..e0405d4d8 100644 --- a/plugins/package.go +++ b/plugins/package.go @@ -72,9 +72,10 @@ func openPackage(ndpPath string) (*ndpPackage, error) { }, nil } -// readManifest reads only the manifest from an .ndp file without loading the wasm bytes. -// This is useful for quick plugin discovery. -func readManifest(ndpPath string) (*Manifest, error) { +// ReadManifest reads and validates the manifest from a .ndp file without loading +// the wasm bytes (it runs ParseManifest, so JSON-schema and cross-field +// validation are applied). Useful for quick plugin discovery and validation. +func ReadManifest(ndpPath string) (*Manifest, error) { // Open the zip archive zr, err := zip.OpenReader(ndpPath) if err != nil { diff --git a/plugins/package_test.go b/plugins/package_test.go index 3d3fec022..4a37f4352 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -132,8 +132,8 @@ var _ = Describe("ndpPackage", func() { }) }) - Describe("readManifest", func() { - It("should read only the manifest without loading wasm", func() { + Describe("ReadManifest", func() { + It("parses the manifest from a package that also contains wasm", func() { ndpPath := filepath.Join(tmpDir, "test.ndp") manifest := &Manifest{ Name: "Test Plugin", @@ -141,57 +141,60 @@ var _ = Describe("ndpPackage", func() { Version: "1.0.0", Description: new("A test plugin"), } - wasmBytes := make([]byte, 1024*1024) // 1MB of zeros - err := createTestPackage(ndpPath, manifest, wasmBytes) + err := createTestPackage(ndpPath, manifest, nil) Expect(err).ToNot(HaveOccurred()) - m, err := readManifest(ndpPath) + m, err := ReadManifest(ndpPath) Expect(err).ToNot(HaveOccurred()) Expect(m.Name).To(Equal("Test Plugin")) Expect(*m.Description).To(Equal("A test plugin")) }) - It("should return error for missing manifest", func() { - ndpPath := filepath.Join(tmpDir, "no-manifest.ndp") + It("returns an error for a non-existent file", func() { + _, err := ReadManifest(filepath.Join(tmpDir, "does-not-exist.ndp")) + Expect(err).To(HaveOccurred()) + }) + It("returns a specific error for a package missing manifest.json", func() { + ndpPath := filepath.Join(tmpDir, "no-manifest.ndp") f, err := os.Create(ndpPath) Expect(err).ToNot(HaveOccurred()) defer f.Close() - zw := newTestZipWriter(f) - err = zw.addFile("plugin.wasm", []byte{0x00}) - Expect(err).ToNot(HaveOccurred()) - err = zw.close() - Expect(err).ToNot(HaveOccurred()) + Expect(zw.addFile("plugin.wasm", []byte{0x00})).To(Succeed()) + Expect(zw.close()).To(Succeed()) - _, err = readManifest(ndpPath) + _, err = ReadManifest(ndpPath) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing manifest.json")) }) - }) - Describe("ComputePackageSHA256", func() { - It("should compute consistent hash for same file", func() { - ndpPath := filepath.Join(tmpDir, "test.ndp") + It("fails for a package with a schema-invalid manifest", func() { + ndp := filepath.Join(tmpDir, "bad.ndp") + // empty required fields violate the manifest JSON schema + err := createTestPackage(ndp, &Manifest{}, nil) + Expect(err).ToNot(HaveOccurred()) + _, err = ReadManifest(ndp) + Expect(err).To(HaveOccurred()) + }) + + It("enforces cross-field validation", func() { + ndp := filepath.Join(tmpDir, "crossfield.ndp") + // subsonicapi permission without users: violates cross-field rule manifest := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Version: "1.0.0", + Name: "X", + Author: "me", + Version: "1.0.0", + Permissions: &Permissions{Subsonicapi: &SubsonicAPIPermission{}}, } - wasmBytes := []byte{0x00, 0x61, 0x73, 0x6d} - - err := createTestPackage(ndpPath, manifest, wasmBytes) + err := createTestPackage(ndp, manifest, nil) Expect(err).ToNot(HaveOccurred()) - hash1, err := computeFileSHA256(ndpPath) - Expect(err).ToNot(HaveOccurred()) - - hash2, err := computeFileSHA256(ndpPath) - Expect(err).ToNot(HaveOccurred()) - - Expect(hash1).To(Equal(hash2)) - Expect(hash1).To(HaveLen(64)) // SHA-256 produces 64 hex characters + _, err = ReadManifest(ndp) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("subsonicapi")) + Expect(err.Error()).To(ContainSubstring("users")) }) }) }) diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index 5781a04c1..ff2c2a07f 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -41,6 +41,7 @@ The following host services are available: - HTTP: provides outbound HTTP request capabilities for plugins. - KVStore: provides persistent key-value storage for plugins. - Library: provides access to music library metadata for plugins. + - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. diff --git a/plugins/pdk/go/host/nd_host_artwork_stub.go b/plugins/pdk/go/host/nd_host_artwork_stub.go index aa41e440c..3b81e0d6b 100644 --- a/plugins/pdk/go/host/nd_host_artwork_stub.go +++ b/plugins/pdk/go/host/nd_host_artwork_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockArtworkService is the mock implementation for testing. type mockArtworkService struct { diff --git a/plugins/pdk/go/host/nd_host_cache_stub.go b/plugins/pdk/go/host/nd_host_cache_stub.go index fbd80d13f..46bb44bcf 100644 --- a/plugins/pdk/go/host/nd_host_cache_stub.go +++ b/plugins/pdk/go/host/nd_host_cache_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockCacheService is the mock implementation for testing. type mockCacheService struct { diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go index 2b8485ce9..463c29b76 100644 --- a/plugins/pdk/go/host/nd_host_config_stub.go +++ b/plugins/pdk/go/host/nd_host_config_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockConfigService is the mock implementation for testing. type mockConfigService struct { diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go index 2f15a91a9..09c4d0fcf 100644 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. diff --git a/plugins/pdk/go/host/nd_host_kvstore_stub.go b/plugins/pdk/go/host/nd_host_kvstore_stub.go index 83b55d3a8..fce038aa1 100644 --- a/plugins/pdk/go/host/nd_host_kvstore_stub.go +++ b/plugins/pdk/go/host/nd_host_kvstore_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockKVStoreService is the mock implementation for testing. type mockKVStoreService struct { diff --git a/plugins/pdk/go/host/nd_host_library_stub.go b/plugins/pdk/go/host/nd_host_library_stub.go index 9ad0d97e7..1e4c230c3 100644 --- a/plugins/pdk/go/host/nd_host_library_stub.go +++ b/plugins/pdk/go/host/nd_host_library_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // Library represents the Library data structure. // Library represents a music library with metadata. diff --git a/plugins/pdk/go/host/nd_host_matcher.go b/plugins/pdk/go/host/nd_host_matcher.go new file mode 100644 index 000000000..b32ac6f9d --- /dev/null +++ b/plugins/pdk/go/host/nd_host_matcher.go @@ -0,0 +1,77 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Matcher host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/types" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// MatchOptions represents the MatchOptions data structure. +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + Username string `json:"username"` +} + +// matcher_matchsongs is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user matcher_matchsongs +func matcher_matchsongs(uint64) uint64 + +type matcherMatchSongsRequest struct { + Songs []types.SongRef `json:"songs"` + Opts MatchOptions `json:"opts"` +} + +type matcherMatchSongsResponse struct { + Results []*types.Track `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +// MatcherMatchSongs calls the matcher_matchsongs host function. +// MatchSongs resolves each input song to its best-matching library track. +// It returns one entry per input song, in the same order as the input; the +// entry for an input song that had no match is empty (absent). Results are +// limited to the libraries the plugin (and the scoped user, if any) can access. +func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + // Marshal request to JSON + req := matcherMatchSongsRequest{ + Songs: songs, + Opts: opts, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := matcher_matchsongs(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response matcherMatchSongsResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Results, nil +} diff --git a/plugins/pdk/go/host/nd_host_matcher_stub.go b/plugins/pdk/go/host/nd_host_matcher_stub.go new file mode 100644 index 000000000..07b34bb82 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_matcher_stub.go @@ -0,0 +1,44 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/types" + "github.com/stretchr/testify/mock" +) + +// MatchOptions represents the MatchOptions data structure. +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + Username string `json:"username"` +} + +// mockMatcherService is the mock implementation for testing. +type mockMatcherService struct { + mock.Mock +} + +// MatcherMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.MatcherMock.On("MethodName", args...).Return(values...) +var MatcherMock = &mockMatcherService{} + +// MatchSongs is the mock method for MatcherMatchSongs. +func (m *mockMatcherService) MatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + args := m.Called(songs, opts) + return args.Get(0).([]*types.Track), args.Error(1) +} + +// MatcherMatchSongs delegates to the mock instance. +// MatchSongs resolves each input song to its best-matching library track. +// It returns one entry per input song, in the same order as the input; the +// entry for an input song that had no match is empty (absent). Results are +// limited to the libraries the plugin (and the scoped user, if any) can access. +func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + return MatcherMock.MatchSongs(songs, opts) +} diff --git a/plugins/pdk/go/host/nd_host_scheduler_stub.go b/plugins/pdk/go/host/nd_host_scheduler_stub.go index 3eaa0087a..ac2a8821f 100644 --- a/plugins/pdk/go/host/nd_host_scheduler_stub.go +++ b/plugins/pdk/go/host/nd_host_scheduler_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockSchedulerService is the mock implementation for testing. type mockSchedulerService struct { diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index 2fdaf2403..6d3a56b35 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockSubsonicAPIService is the mock implementation for testing. type mockSubsonicAPIService struct { diff --git a/plugins/pdk/go/host/nd_host_task_stub.go b/plugins/pdk/go/host/nd_host_task_stub.go index 4dde0e234..286d14035 100644 --- a/plugins/pdk/go/host/nd_host_task_stub.go +++ b/plugins/pdk/go/host/nd_host_task_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // QueueConfig represents the QueueConfig data structure. // QueueConfig holds configuration for a task queue. diff --git a/plugins/pdk/go/host/nd_host_users_stub.go b/plugins/pdk/go/host/nd_host_users_stub.go index f76854894..8858b2109 100644 --- a/plugins/pdk/go/host/nd_host_users_stub.go +++ b/plugins/pdk/go/host/nd_host_users_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // User represents the User data structure. // User represents a Navidrome user with minimal information exposed to plugins. diff --git a/plugins/pdk/go/host/nd_host_websocket_stub.go b/plugins/pdk/go/host/nd_host_websocket_stub.go index 23ac382f0..ca6e39c44 100644 --- a/plugins/pdk/go/host/nd_host_websocket_stub.go +++ b/plugins/pdk/go/host/nd_host_websocket_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockWebSocketService is the mock implementation for testing. type mockWebSocketService struct { diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 6696bc4a1..b3c7f29ac 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -9,17 +9,11 @@ package lyrics import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -51,9 +45,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index fb3e3fb1a..76c54d2e5 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -8,15 +8,10 @@ package lyrics -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -48,9 +43,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index 7cd63865b..c561c2893 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -9,8 +9,15 @@ package metadata import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -65,16 +72,6 @@ type ArtistMBIDResponse struct { MBID string `json:"mbid"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // ArtistRequest is the common request for artist-related functions. type ArtistRequest struct { // ID is the internal Navidrome artist ID. @@ -114,7 +111,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -160,29 +157,7 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Songs []types.SongRef `json:"songs"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -200,7 +175,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index bdcd06fcb..e72cca103 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -8,6 +8,14 @@ package metadata +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -62,16 +70,6 @@ type ArtistMBIDResponse struct { MBID string `json:"mbid"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // ArtistRequest is the common request for artist-related functions. type ArtistRequest struct { // ID is the internal Navidrome artist ID. @@ -111,7 +109,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -157,29 +155,7 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Songs []types.SongRef `json:"songs"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -197,7 +173,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index 0d045e597..22ae4c3bf 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -9,8 +9,12 @@ package scrobbler import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string @@ -26,16 +30,6 @@ const ( // Error implements the error interface for ScrobblerError. func (e ScrobblerError) Error() string { return string(e) } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // IsAuthorizedRequest is the request for authorization check. type IsAuthorizedRequest struct { // Username is the username of the user. @@ -95,9 +89,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index b35e7c40e..722ec5462 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -8,6 +8,11 @@ package scrobbler +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string @@ -23,16 +28,6 @@ const ( // Error implements the error interface for ScrobblerError. func (e ScrobblerError) Error() string { return string(e) } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // IsAuthorizedRequest is the request for authorization check. type IsAuthorizedRequest struct { // Username is the username of the user. @@ -92,9 +87,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go index 7a2681f93..fca8bd2c1 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -9,47 +9,32 @@ package sonicsimilarity import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } // SonicSimilarityResponse represents the SonicSimilarityResponse data structure. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go index 28d86301c..518a166e5 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -8,45 +8,31 @@ package sonicsimilarity +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } // SonicSimilarityResponse represents the SonicSimilarityResponse data structure. diff --git a/plugins/pdk/go/types/types.go b/plugins/pdk/go/types/types.go new file mode 100644 index 000000000..4d4cdedd2 --- /dev/null +++ b/plugins/pdk/go/types/types.go @@ -0,0 +1,148 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// Package types holds the stable, shared data types exchanged between +// Navidrome and its plugins. These types are referenced by host services and +// capability wrappers via the types package. + +package types + +// ArtistRef is the minimal information a plugin returns for Navidrome to match an +// artist against the library. It is a reference, not a full artist entity: it +// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +// few projection fields used when describing a track's participants, never +// descriptive data such as biographies or images. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` + // SortName is the artist name used for sorting (if known). + SortName string `json:"sortName,omitempty"` + // Role is the participation category (e.g. "artist", "composer", "performer"). + Role string `json:"role,omitempty"` + // SubRole is a specialization within Role (e.g. the instrument for a performer). + SubRole string `json:"subRole,omitempty"` +} + +// SongRef is the minimal information exchanged between a plugin and Navidrome to +// match a song. It is used both as input (a song Navidrome already has) and as +// output (a song a plugin suggests, which may not be in the library yet). Unlike +// Track, it is an abstract recording reference carrying only matching keys (IDs, +// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + // + // Deprecated: use Artists. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + // + // Deprecated: use Artists. + ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + // + // Deprecated: use DurationMs, which carries millisecond precision. When + // DurationMs is non-zero it takes precedence; Duration is kept only for + // backwards compatibility with plugins that still send seconds. + Duration float32 `json:"duration,omitempty"` + // DurationMs is the song duration in milliseconds. It supersedes Duration + // when non-zero. + DurationMs uint32 `json:"durationMs,omitempty"` +} + +// Track is a stable, public projection of a library media file for plugin consumption. +// It is a sane subset of the internal model.MediaFile, intended for reuse across host +// services and capabilities. Timestamps are Unix epoch seconds. +// +// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +// Track is a concrete library entity: it identifies a specific media file that exists +// (or once existed) in the library and exposes its full descriptive metadata. +type Track struct { + // Identity & location + ID string `json:"id"` + LibraryID int32 `json:"libraryId"` + LibraryName string `json:"libraryName,omitempty"` + Path string `json:"path,omitempty"` + Missing bool `json:"missing"` + // Core metadata + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumArtist string `json:"albumArtist,omitempty"` + AlbumID string `json:"albumId,omitempty"` + SortTitle string `json:"sortTitle,omitempty"` + SortAlbumName string `json:"sortAlbumName,omitempty"` + SortArtistName string `json:"sortArtistName,omitempty"` + // Track / disc / dates + TrackNumber int32 `json:"trackNumber"` + DiscNumber int32 `json:"discNumber"` + DiscSubtitle string `json:"discSubtitle,omitempty"` + Year int32 `json:"year"` + Date string `json:"date,omitempty"` + OriginalYear int32 `json:"originalYear"` + OriginalDate string `json:"originalDate,omitempty"` + ReleaseYear int32 `json:"releaseYear"` + ReleaseDate string `json:"releaseDate,omitempty"` + // Audio / file + Size int64 `json:"size"` + Suffix string `json:"suffix,omitempty"` + Duration float64 `json:"duration"` + BitRate int32 `json:"bitRate"` + SampleRate int32 `json:"sampleRate"` + BitDepth *int32 `json:"bitDepth,omitempty"` + Channels int32 `json:"channels"` + Codec string `json:"codec,omitempty"` + // Descriptive + Genres []string `json:"genres,omitempty"` + Comment string `json:"comment,omitempty"` + BPM *int32 `json:"bpm,omitempty"` + ExplicitStatus string `json:"explicitStatus,omitempty"` + CatalogNum string `json:"catalogNum,omitempty"` + Compilation bool `json:"compilation"` + HasCoverArt bool `json:"hasCoverArt"` + // MusicBrainz + MbzRecordingID string `json:"mbzRecordingId,omitempty"` + MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + MbzAlbumType string `json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` + // ReplayGain — nil means no data; 0 is a valid measured value, so these + // must stay pointers to distinguish "absent" from "0". + RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` + RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` + RGTrackGain *float64 `json:"rgTrackGain,omitempty"` + RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` + // Timestamps (Unix epoch seconds) + BirthTime int64 `json:"birthTime"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + // AverageRating is the track's mean rating across all users (always set; 0 when unrated). + AverageRating float64 `json:"averageRating"` + // Per-user annotations, set only for a user-scoped match. Timestamps are Unix + // seconds; a nil pointer means "no value". + Starred bool `json:"starred,omitempty"` + StarredAt *int64 `json:"starredAt,omitempty"` + Rating int32 `json:"rating,omitempty"` + PlayCount int64 `json:"playCount,omitempty"` + PlayDate *int64 `json:"playDate,omitempty"` + // Composite + Tags map[string][]string `json:"tags,omitempty"` + // Participants lists the track's artists across all roles, each tagged with its Role. + Participants []ArtistRef `json:"participants,omitempty"` +} diff --git a/plugins/pdk/python/host/nd_host_artwork.py b/plugins/pdk/python/host/nd_host_artwork.py deleted file mode 100644 index 9bcb529ae..000000000 --- a/plugins/pdk/python/host/nd_host_artwork.py +++ /dev/null @@ -1,183 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Artwork host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "artwork_getartisturl") -def _artwork_getartisturl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_getalbumurl") -def _artwork_getalbumurl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_gettrackurl") -def _artwork_gettrackurl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_getplaylisturl") -def _artwork_getplaylisturl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def artwork_get_artist_url(id: str, size: int) -> str: - """GetArtistUrl generates a public URL for an artist's artwork. - -Parameters: - - id: The artist's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getartisturl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_album_url(id: str, size: int) -> str: - """GetAlbumUrl generates a public URL for an album's artwork. - -Parameters: - - id: The album's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getalbumurl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_track_url(id: str, size: int) -> str: - """GetTrackUrl generates a public URL for a track's artwork. - -Parameters: - - id: The track's (media file) unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_gettrackurl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_playlist_url(id: str, size: int) -> str: - """GetPlaylistUrl generates a public URL for a playlist's artwork. - -Parameters: - - id: The playlist's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getplaylisturl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") diff --git a/plugins/pdk/python/host/nd_host_cache.py b/plugins/pdk/python/host/nd_host_cache.py deleted file mode 100644 index b24e983cc..000000000 --- a/plugins/pdk/python/host/nd_host_cache.py +++ /dev/null @@ -1,448 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Cache host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "cache_setstring") -def _cache_setstring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getstring") -def _cache_getstring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setint") -def _cache_setint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getint") -def _cache_getint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setfloat") -def _cache_setfloat(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getfloat") -def _cache_getfloat(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setbytes") -def _cache_setbytes(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getbytes") -def _cache_getbytes(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_has") -def _cache_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_remove") -def _cache_remove(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class CacheGetStringResult: - """Result type for cache_get_string.""" - value: str - exists: bool - - -@dataclass -class CacheGetIntResult: - """Result type for cache_get_int.""" - value: int - exists: bool - - -@dataclass -class CacheGetFloatResult: - """Result type for cache_get_float.""" - value: float - exists: bool - - -@dataclass -class CacheGetBytesResult: - """Result type for cache_get_bytes.""" - value: bytes - exists: bool - - -def cache_set_string(key: str, value: str, ttl_seconds: int) -> None: - """SetString stores a string value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The string value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: str parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setstring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_string(key: str) -> CacheGetStringResult: - """GetString retrieves a string value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a string, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetStringResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getstring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetStringResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def cache_set_int(key: str, value: int, ttl_seconds: int) -> None: - """SetInt stores an integer value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The integer value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: int parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_int(key: str) -> CacheGetIntResult: - """GetInt retrieves an integer value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not an integer, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetIntResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetIntResult( - value=response.get("value", 0), - exists=response.get("exists", False), - ) - - -def cache_set_float(key: str, value: float, ttl_seconds: int) -> None: - """SetFloat stores a float value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The float value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: float parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setfloat(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_float(key: str) -> CacheGetFloatResult: - """GetFloat retrieves a float value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a float, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetFloatResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getfloat(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetFloatResult( - value=response.get("value", 0.0), - exists=response.get("exists", False), - ) - - -def cache_set_bytes(key: str, value: bytes, ttl_seconds: int) -> None: - """SetBytes stores a byte slice in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The byte slice to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setbytes(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_bytes(key: str) -> CacheGetBytesResult: - """GetBytes retrieves a byte slice from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a byte slice, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetBytesResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getbytes(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetBytesResult( - value=base64.b64decode(response.get("value", "")), - exists=response.get("exists", False), - ) - - -def cache_has(key: str) -> bool: - """Has checks if a key exists in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns true if the key exists and has not expired. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) - - -def cache_remove(key: str) -> None: - """Remove deletes a value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns an error if the operation fails. Does not return an error if the key doesn't exist. - - Args: - key: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_remove(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/python/host/nd_host_config.py b/plugins/pdk/python/host/nd_host_config.py deleted file mode 100644 index 1dab2fe0e..000000000 --- a/plugins/pdk/python/host/nd_host_config.py +++ /dev/null @@ -1,145 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Config host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "config_get") -def _config_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_getint") -def _config_getint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_keys") -def _config_keys(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ConfigGetResult: - """Result type for config_get.""" - value: str - exists: bool - - -@dataclass -class ConfigGetIntResult: - """Result type for config_get_int.""" - value: int - exists: bool - - -def config_get(key: str) -> ConfigGetResult: - """Get retrieves a configuration value as a string. - -Parameters: - - key: The configuration key - -Returns the value and whether the key exists. - - Args: - key: str parameter. - - Returns: - ConfigGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return ConfigGetResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def config_get_int(key: str) -> ConfigGetIntResult: - """GetInt retrieves a configuration value as an integer. - -Parameters: - - key: The configuration key - -Returns the value and whether the key exists. If the key exists but the -value cannot be parsed as an integer, exists will be false. - - Args: - key: str parameter. - - Returns: - ConfigGetIntResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_getint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return ConfigGetIntResult( - value=response.get("value", 0), - exists=response.get("exists", False), - ) - - -def config_keys(prefix: str) -> Any: - """Keys returns configuration keys matching the given prefix. - -Parameters: - - prefix: Key prefix to filter by. If empty, returns all keys. - -Returns a sorted slice of matching configuration keys. - - Args: - prefix: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_keys(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return response.get("keys", None) diff --git a/plugins/pdk/python/host/nd_host_http.py b/plugins/pdk/python/host/nd_host_http.py deleted file mode 100644 index a806c8456..000000000 --- a/plugins/pdk/python/host/nd_host_http.py +++ /dev/null @@ -1,60 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the HTTP host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def http_send(request: Any) -> Any: - """Send executes an HTTP request and returns the response. - -Parameters: - - request: The HTTP request to execute, including method, URL, headers, body, and timeout - -Returns the HTTP response with status code, headers, and body. -Network errors, timeouts, and permission failures are returned as Go errors. -Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. - - Args: - request: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "request": request, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _http_send(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_httpclient.py b/plugins/pdk/python/host/nd_host_httpclient.py deleted file mode 100644 index c6bfb77c0..000000000 --- a/plugins/pdk/python/host/nd_host_httpclient.py +++ /dev/null @@ -1,59 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the HTTP host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def http_send(request: Any) -> Any: - """Send executes an HTTP request and returns the response. - -Parameters: - - request: The HTTP request to execute, including method, URL, headers, body, and timeout - -Returns the HTTP response with status code, headers, and body. -Network errors, timeouts, and permission failures are returned as errors. -Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. - - Args: - request: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "request": request, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _http_send(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_kvstore.py b/plugins/pdk/python/host/nd_host_kvstore.py deleted file mode 100644 index 33eaffc52..000000000 --- a/plugins/pdk/python/host/nd_host_kvstore.py +++ /dev/null @@ -1,362 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the KVStore host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "kvstore_set") -def _kvstore_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_setwithttl") -def _kvstore_setwithttl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_get") -def _kvstore_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_getmany") -def _kvstore_getmany(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_has") -def _kvstore_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_list") -def _kvstore_list(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_delete") -def _kvstore_delete(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_deletebyprefix") -def _kvstore_deletebyprefix(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_getstorageused") -def _kvstore_getstorageused(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class KVStoreGetResult: - """Result type for kvstore_get.""" - value: bytes - exists: bool - - -def kvstore_set(key: str, value: bytes) -> None: - """Set stores a byte value with the given key. - -Parameters: - - key: The storage key (max 256 bytes, UTF-8) - - value: The byte slice to store - -Returns an error if the storage limit would be exceeded or the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_set_with_ttl(key: str, value: bytes, ttl_seconds: int) -> None: - """SetWithTTL stores a byte value with the given key and a time-to-live. - -After ttlSeconds, the key is treated as non-existent and will be -cleaned up lazily. ttlSeconds must be greater than 0. - -Parameters: - - key: The storage key (max 256 bytes, UTF-8) - - value: The byte slice to store - - ttlSeconds: Time-to-live in seconds (must be > 0) - -Returns an error if the storage limit would be exceeded or the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_setwithttl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_get(key: str) -> KVStoreGetResult: - """Get retrieves a byte value from storage. - -Parameters: - - key: The storage key - -Returns the value and whether the key exists. - - Args: - key: str parameter. - - Returns: - KVStoreGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return KVStoreGetResult( - value=base64.b64decode(response.get("value", "")), - exists=response.get("exists", False), - ) - - -def kvstore_get_many(keys: Any) -> Any: - """GetMany retrieves multiple values in a single call. - -Parameters: - - keys: The storage keys to retrieve - -Returns a map of key to value for keys that exist and have not expired. -Missing or expired keys are omitted from the result. - - Args: - keys: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "keys": keys, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_getmany(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("values", None) - - -def kvstore_has(key: str) -> bool: - """Has checks if a key exists in storage. - -Parameters: - - key: The storage key - -Returns true if the key exists. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) - - -def kvstore_list(prefix: str) -> Any: - """List returns all keys matching the given prefix. - -Parameters: - - prefix: Key prefix to filter by (empty string returns all keys) - -Returns a slice of matching keys. - - Args: - prefix: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_list(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("keys", None) - - -def kvstore_delete(key: str) -> None: - """Delete removes a value from storage. - -Parameters: - - key: The storage key - -Returns an error if the operation fails. Does not return an error if the key doesn't exist. - - Args: - key: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_delete(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_delete_by_prefix(prefix: str) -> int: - """DeleteByPrefix removes all keys matching the given prefix. - -Parameters: - - prefix: Key prefix to match (must not be empty) - -Returns the number of keys deleted. Includes expired keys. - - Args: - prefix: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_deletebyprefix(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("deletedCount", 0) - - -def kvstore_get_storage_used() -> int: - """GetStorageUsed returns the total storage used by this plugin in bytes. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_getstorageused(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("bytes", 0) diff --git a/plugins/pdk/python/host/nd_host_library.py b/plugins/pdk/python/host/nd_host_library.py deleted file mode 100644 index 12e1bc4eb..000000000 --- a/plugins/pdk/python/host/nd_host_library.py +++ /dev/null @@ -1,86 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Library host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "library_getlibrary") -def _library_getlibrary(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "library_getalllibraries") -def _library_getalllibraries(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def library_get_library(id: int) -> Any: - """GetLibrary retrieves metadata for a specific library by ID. - -Parameters: - - id: The library's unique identifier - -Returns the library metadata, or an error if the library is not found. - - Args: - id: int parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _library_getlibrary(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def library_get_all_libraries() -> Any: - """GetAllLibraries retrieves metadata for all configured libraries. - -Returns a slice of all libraries with their metadata. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _library_getalllibraries(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_scheduler.py b/plugins/pdk/python/host/nd_host_scheduler.py deleted file mode 100644 index 7f0d19241..000000000 --- a/plugins/pdk/python/host/nd_host_scheduler.py +++ /dev/null @@ -1,143 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Scheduler host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "scheduler_scheduleonetime") -def _scheduler_scheduleonetime(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "scheduler_schedulerecurring") -def _scheduler_schedulerecurring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "scheduler_cancelschedule") -def _scheduler_cancelschedule(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def scheduler_schedule_one_time(delay_seconds: int, payload: str, schedule_id: str) -> str: - """ScheduleOneTime schedules a one-time event to be triggered after the specified delay. -Plugins that use this function must also implement the SchedulerCallback capability - -Parameters: - - delaySeconds: Number of seconds to wait before triggering the event - - payload: Data to be passed to the scheduled event handler - - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - -Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - - Args: - delay_seconds: int parameter. - payload: str parameter. - schedule_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "delaySeconds": delay_seconds, - "payload": payload, - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_scheduleonetime(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newScheduleId", "") - - -def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id: str) -> str: - """ScheduleRecurring schedules a recurring event using a cron expression. -Plugins that use this function must also implement the SchedulerCallback capability - -Parameters: - - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) - - payload: Data to be passed to each scheduled event handler invocation - - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - -Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - - Args: - cron_expression: str parameter. - payload: str parameter. - schedule_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "cronExpression": cron_expression, - "payload": payload, - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_schedulerecurring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newScheduleId", "") - - -def scheduler_cancel_schedule(schedule_id: str) -> None: - """CancelSchedule cancels a scheduled job identified by its schedule ID. - -This works for both one-time and recurring schedules. Once cancelled, the job will not trigger -any future events. - -Returns an error if the schedule ID is not found or if cancellation fails. - - Args: - schedule_id: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_cancelschedule(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/python/host/nd_host_subsonicapi.py b/plugins/pdk/python/host/nd_host_subsonicapi.py deleted file mode 100644 index cf35bc043..000000000 --- a/plugins/pdk/python/host/nd_host_subsonicapi.py +++ /dev/null @@ -1,101 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the SubsonicAPI host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "subsonicapi_call") -def _subsonicapi_call(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "subsonicapi_callraw") -def _subsonicapi_callraw(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class SubsonicAPICallRawResult: - """Result type for subsonicapi_call_raw.""" - content_type: str - data: bytes - - -def subsonicapi_call(uri: str) -> str: - """Call executes a Subsonic API request and returns the JSON response. - -The uri parameter should be the Subsonic API path without the server prefix, -e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. - - Args: - uri: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "uri": uri, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _subsonicapi_call(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("responseJson", "") - - -def subsonicapi_call_raw(uri: str) -> SubsonicAPICallRawResult: - """CallRaw executes a Subsonic API request and returns the raw binary response. -Designed for binary endpoints like getCoverArt and stream that return -non-JSON data. The data is base64-encoded over JSON on the wire. - - Args: - uri: str parameter. - - Returns: - SubsonicAPICallRawResult containing content_type, data,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "uri": uri, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _subsonicapi_callraw(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return SubsonicAPICallRawResult( - content_type=response.get("contentType", ""), - data=base64.b64decode(response.get("data", "")), - ) diff --git a/plugins/pdk/python/host/nd_host_task.py b/plugins/pdk/python/host/nd_host_task.py deleted file mode 100644 index 5d6e7474c..000000000 --- a/plugins/pdk/python/host/nd_host_task.py +++ /dev/null @@ -1,188 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Task host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "task_createqueue") -def _task_createqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_enqueue") -def _task_enqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_get") -def _task_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_cancel") -def _task_cancel(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_clearqueue") -def _task_clearqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def task_create_queue(name: str, config: Any) -> None: - """CreateQueue creates a named task queue with the given configuration. -Zero-value fields in config use sensible defaults. -If a queue with the same name already exists, returns an error. -On startup, this also recovers any stale "running" tasks from a previous crash. - - Args: - name: str parameter. - config: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "config": config, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_createqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def task_enqueue(queue_name: str, payload: bytes) -> str: - """Enqueue adds a task to the named queue. Returns the task ID. -payload is opaque bytes passed back to the plugin on execution. - - Args: - queue_name: str parameter. - payload: bytes parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "queueName": queue_name, - "payload": base64.b64encode(payload).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_enqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def task_get(task_id: str) -> Any: - """Get returns the current state of a task including its status, -message, and attempt count. - - Args: - task_id: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "taskId": task_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def task_cancel(task_id: str) -> None: - """Cancel cancels a pending task. Returns error if already -running, completed, or failed. - - Args: - task_id: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "taskId": task_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_cancel(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def task_clear_queue(queue_name: str) -> int: - """ClearQueue removes all pending tasks from the named queue. -Running tasks are not affected. Returns the number of tasks removed. - - Args: - queue_name: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "queueName": queue_name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_clearqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) diff --git a/plugins/pdk/python/host/nd_host_users.py b/plugins/pdk/python/host/nd_host_users.py deleted file mode 100644 index a325156a7..000000000 --- a/plugins/pdk/python/host/nd_host_users.py +++ /dev/null @@ -1,80 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Users host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "users_getusers") -def _users_getusers(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "users_getadmins") -def _users_getadmins(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def users_get_users() -> Any: - """GetUsers returns all users the plugin has been granted access to. -Only minimal user information (userName, name, isAdmin) is returned. -Sensitive fields like password and email are never exposed. - -Returns a slice of users the plugin can access, or an empty slice if none configured. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_getusers(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def users_get_admins() -> Any: - """GetAdmins returns only admin users the plugin has been granted access to. -This is a convenience method that filters GetUsers results to include only admins. - -Returns a slice of admin users the plugin can access, or an empty slice if none. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_getadmins(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_websocket.py b/plugins/pdk/python/host/nd_host_websocket.py deleted file mode 100644 index 4e882914c..000000000 --- a/plugins/pdk/python/host/nd_host_websocket.py +++ /dev/null @@ -1,182 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the WebSocket host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "websocket_connect") -def _websocket_connect(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_sendtext") -def _websocket_sendtext(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_sendbinary") -def _websocket_sendbinary(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_closeconnection") -def _websocket_closeconnection(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def websocket_connect(url: str, headers: Any, connection_id: str) -> str: - """Connect establishes a WebSocket connection to the specified URL. - -Plugins that use this function must also implement the WebSocketCallback capability -to receive incoming messages and connection events. - -Parameters: - - url: The WebSocket URL to connect to (ws:// or wss://) - - headers: Optional HTTP headers to include in the handshake request - - connectionID: Optional unique identifier for the connection. If empty, one will be generated - -Returns the connection ID that can be used to send messages or close the connection, -or an error if the connection fails. - - Args: - url: str parameter. - headers: Any parameter. - connection_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "url": url, - "headers": headers, - "connectionId": connection_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_connect(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newConnectionId", "") - - -def websocket_send_text(connection_id: str, message: str) -> None: - """SendText sends a text message over an established WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - message: The text message to send - -Returns an error if the connection is not found or if sending fails. - - Args: - connection_id: str parameter. - message: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "message": message, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_sendtext(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def websocket_send_binary(connection_id: str, data: bytes) -> None: - """SendBinary sends binary data over an established WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - data: The binary data to send - -Returns an error if the connection is not found or if sending fails. - - Args: - connection_id: str parameter. - data: bytes parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_sendbinary(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def websocket_close_connection(connection_id: str, code: int, reason: str) -> None: - """CloseConnection gracefully closes a WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - code: WebSocket close status code (e.g., 1000 for normal closure) - - reason: Optional human-readable reason for closing - -Returns an error if the connection is not found or if closing fails. - - Args: - connection_id: str parameter. - code: int parameter. - reason: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "code": code, - "reason": reason, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_closeconnection(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml index 443f19da5..e9fe367da 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" crate-type = ["rlib"] [dependencies] +nd-pdk-types = { path = "../nd-pdk-types" } base64 = "0.22" extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index b0361a3a3..d3d2b19aa 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -5,6 +5,8 @@ //! This crate provides type definitions, traits, and registration macros //! for implementing Navidrome plugin capabilities in Rust. +pub use nd_pdk_types as types; + pub mod lifecycle; pub mod lyrics; pub mod metadata; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index 0a861a1ed..a8449a5ec 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -18,20 +18,9 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; /// GetLyricsRequest contains the track information for lyrics lookup. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -77,10 +66,10 @@ pub struct TrackInfo { pub album_artist: String, /// Artists is the list of track artists. #[serde(default)] - pub artists: Vec, + pub artists: Vec, /// AlbumArtists is the list of album artists. #[serde(default)] - pub album_artists: Vec, + pub album_artists: Vec, /// Duration is the track duration in seconds. #[serde(default)] pub duration: f32, diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index 463e52c37..38fcae9da 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -18,6 +18,12 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; + +#[deprecated(note = "use nd_pdk::types::SongRef")] +pub type SongRef = nd_pdk_types::SongRef; /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -92,20 +98,6 @@ pub struct ArtistMBIDResponse { #[serde(default)] pub mbid: String, } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} /// ArtistRequest is the common request for artist-related functions. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -162,7 +154,7 @@ pub struct SimilarArtistsRequest { pub struct SimilarArtistsResponse { /// Artists is the list of similar artists. #[serde(default)] - pub artists: Vec, + pub artists: Vec, } /// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -227,39 +219,7 @@ pub struct SimilarSongsByTrackRequest { pub struct SimilarSongsResponse { /// Songs is the list of similar songs. #[serde(default)] - pub songs: Vec, -} -/// SongRef is a reference to a song with metadata for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SongRef { - /// ID is the internal Navidrome mediafile ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the song name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// ISRC is the International Standard Recording Code for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub isrc: String, - /// Artist is the artist name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist: String, - /// ArtistMBID is the MusicBrainz artist ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist_mbid: String, - /// Album is the album name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album: String, - /// AlbumMBID is the MusicBrainz release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_mbid: String, - /// Duration is the song duration in seconds. - #[serde(default, skip_serializing_if = "is_zero_f32")] - pub duration: f32, + pub songs: Vec, } /// TopSongsRequest is the request for GetArtistTopSongs. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -284,7 +244,7 @@ pub struct TopSongsRequest { pub struct TopSongsResponse { /// Songs is the list of top songs. #[serde(default)] - pub songs: Vec, + pub songs: Vec, } /// Error represents an error from a capability method. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 1e9c51375..b0b843e6c 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -18,6 +18,9 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; /// ScrobblerError represents an error type for scrobbling operations. pub type ScrobblerError = &'static str; /// ScrobblerErrorNotAuthorized indicates the user is not authorized. @@ -26,20 +29,6 @@ pub const SCROBBLER_ERROR_NOT_AUTHORIZED: ScrobblerError = "scrobbler(not_author pub const SCROBBLER_ERROR_RETRY_LATER: ScrobblerError = "scrobbler(retry_later)"; /// ScrobblerErrorUnrecoverable indicates an unrecoverable error. pub const SCROBBLER_ERROR_UNRECOVERABLE: ScrobblerError = "scrobbler(unrecoverable)"; -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} /// IsAuthorizedRequest is the request for authorization check. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -126,10 +115,10 @@ pub struct TrackInfo { pub album_artist: String, /// Artists is the list of track artists. #[serde(default)] - pub artists: Vec, + pub artists: Vec, /// AlbumArtists is the list of album artists. #[serde(default)] - pub album_artists: Vec, + pub album_artists: Vec, /// Duration is the track duration in seconds. #[serde(default)] pub duration: f32, diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs index eb2868929..a28df9c0a 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -18,14 +18,20 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; + +#[deprecated(note = "use nd_pdk::types::SongRef")] +pub type SongRef = nd_pdk_types::SongRef; /// FindSonicPathRequest represents the FindSonicPathRequest data structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FindSonicPathRequest { #[serde(default)] - pub start_song: SongRef, + pub start_song: nd_pdk_types::SongRef, #[serde(default)] - pub end_song: SongRef, + pub end_song: nd_pdk_types::SongRef, #[serde(default)] pub count: i32, } @@ -34,48 +40,16 @@ pub struct FindSonicPathRequest { #[serde(rename_all = "camelCase")] pub struct GetSonicSimilarTracksRequest { #[serde(default)] - pub song: SongRef, + pub song: nd_pdk_types::SongRef, #[serde(default)] pub count: i32, } -/// SongRef is a reference to a song with metadata for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SongRef { - /// ID is the internal Navidrome mediafile ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the song name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// ISRC is the International Standard Recording Code for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub isrc: String, - /// Artist is the artist name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist: String, - /// ArtistMBID is the MusicBrainz artist ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist_mbid: String, - /// Album is the album name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album: String, - /// AlbumMBID is the MusicBrainz release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_mbid: String, - /// Duration is the song duration in seconds. - #[serde(default, skip_serializing_if = "is_zero_f32")] - pub duration: f32, -} /// SonicMatch represents the SonicMatch data structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SonicMatch { #[serde(default)] - pub song: SongRef, + pub song: nd_pdk_types::SongRef, #[serde(default)] pub similarity: f64, } diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.toml b/plugins/pdk/rust/nd-pdk-host/Cargo.toml index 519096110..100ce6a35 100644 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.toml @@ -13,5 +13,6 @@ crate-type = ["rlib"] [dependencies] base64 = "0.22" extism-pdk = "1.2" +nd-pdk-types = { path = "../nd-pdk-types" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 3a31bc489..cc1fdc190 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -38,6 +38,7 @@ //! - [`http`] - provides outbound HTTP request capabilities for plugins. //! - [`kvstore`] - provides persistent key-value storage for plugins. //! - [`library`] - provides access to music library metadata for plugins. +//! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. @@ -86,6 +87,13 @@ pub mod library { pub use super::nd_host_library::*; } +#[doc(hidden)] +mod nd_host_matcher; +/// resolves externally-obtained songs to local library tracks, +pub mod matcher { + pub use super::nd_host_matcher::*; +} + #[doc(hidden)] mod nd_host_scheduler; /// provides task scheduling capabilities for plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs new file mode 100644 index 000000000..be2819257 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs @@ -0,0 +1,65 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Matcher host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// MatchOptions carries optional parameters for a match request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MatchOptions { + #[serde(default)] + pub username: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct MatcherMatchSongsRequest { + songs: Vec, + opts: MatchOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MatcherMatchSongsResponse { + #[serde(default)] + results: Vec>, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn matcher_matchsongs(input: Json) -> Json; +} + +/// MatchSongs resolves each input song to its best-matching library track. +/// It returns one entry per input song, in the same order as the input; the +/// entry for an input song that had no match is empty (absent). Results are +/// limited to the libraries the plugin (and the scoped user, if any) can access. +/// +/// # Arguments +/// * `songs` - Vec parameter. +/// * `opts` - MatchOptions parameter. +/// +/// # Returns +/// The results value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn match_songs(songs: Vec, opts: MatchOptions) -> Result>, Error> { + let response = unsafe { + matcher_matchsongs(Json(MatcherMatchSongsRequest { + songs: songs, + opts: opts, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.results) +} diff --git a/plugins/pdk/rust/nd-pdk-types/Cargo.toml b/plugins/pdk/rust/nd-pdk-types/Cargo.toml new file mode 100644 index 000000000..e20e07021 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-types/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nd-pdk-types" +version = "0.1.0" +edition = "2021" +description = "Navidrome shared plugin data types for Rust" +authors = ["Navidrome Team"] +license = "GPL-3.0" + +[lib] +path = "src/lib.rs" +crate-type = ["rlib"] + +[dependencies] +base64 = "0.22" +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/pdk/rust/nd-pdk-types/src/lib.rs b/plugins/pdk/rust/nd-pdk-types/src/lib.rs new file mode 100644 index 000000000..fa1a87048 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-types/src/lib.rs @@ -0,0 +1,239 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +//! Navidrome shared plugin data types. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// ArtistRef is the minimal information a plugin returns for Navidrome to match an +/// artist against the library. It is a reference, not a full artist entity: it +/// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +/// few projection fields used when describing a track's participants, never +/// descriptive data such as biographies or images. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRef { + /// ID is the internal Navidrome artist ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// SortName is the artist name used for sorting (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_name: String, + /// Role is the participation category (e.g. "artist", "composer", "performer"). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub role: String, + /// SubRole is a specialization within Role (e.g. the instrument for a performer). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sub_role: String, +} +/// SongRef is the minimal information exchanged between a plugin and Navidrome to +/// match a song. It is used both as input (a song Navidrome already has) and as +/// output (a song a plugin suggests, which may not be in the library yet). Unlike +/// Track, it is an abstract recording reference carrying only matching keys (IDs, +/// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// ID is the internal Navidrome mediafile ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + /// + /// Deprecated: use Artists. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + /// + /// Deprecated: use Artists. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artists: Vec, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + /// + /// Deprecated: use DurationMs, which carries millisecond precision. When + /// DurationMs is non-zero it takes precedence; Duration is kept only for + /// backwards compatibility with plugins that still send seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, + /// DurationMs is the song duration in milliseconds. It supersedes Duration + /// when non-zero. + #[serde(default, skip_serializing_if = "is_zero_u32")] + pub duration_ms: u32, +} +/// Track is a stable, public projection of a library media file for plugin consumption. +/// It is a sane subset of the internal model.MediaFile, intended for reuse across host +/// services and capabilities. Timestamps are Unix epoch seconds. +/// +/// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +/// Track is a concrete library entity: it identifies a specific media file that exists +/// (or once existed) in the library and exposes its full descriptive metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Track { + /// Identity & location + #[serde(default)] + pub id: String, + #[serde(default)] + pub library_id: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub library_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, + #[serde(default)] + pub missing: bool, + /// Core metadata + #[serde(default)] + pub title: String, + #[serde(default)] + pub album: String, + #[serde(default)] + pub artist: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_artist: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_title: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_album_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_artist_name: String, + /// Track / disc / dates + #[serde(default)] + pub track_number: i32, + #[serde(default)] + pub disc_number: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub disc_subtitle: String, + #[serde(default)] + pub year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub date: String, + #[serde(default)] + pub original_year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub original_date: String, + #[serde(default)] + pub release_year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub release_date: String, + /// Audio / file + #[serde(default)] + pub size: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub suffix: String, + #[serde(default)] + pub duration: f64, + #[serde(default)] + pub bit_rate: i32, + #[serde(default)] + pub sample_rate: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bit_depth: Option, + #[serde(default)] + pub channels: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub codec: String, + /// Descriptive + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub genres: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub comment: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bpm: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub explicit_status: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub catalog_num: String, + #[serde(default)] + pub compilation: bool, + #[serde(default)] + pub has_cover_art: bool, + /// MusicBrainz + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_recording_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_track_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_group_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_type: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_comment: String, + /// ReplayGain — nil means no data; 0 is a valid measured value, so these + /// must stay pointers to distinguish "absent" from "0". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_album_gain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_album_peak: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_track_gain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_track_peak: Option, + /// Timestamps (Unix epoch seconds) + #[serde(default)] + pub birth_time: i64, + #[serde(default)] + pub created_at: i64, + #[serde(default)] + pub updated_at: i64, + /// AverageRating is the track's mean rating across all users (always set; 0 when unrated). + #[serde(default)] + pub average_rating: f64, + /// Per-user annotations, set only for a user-scoped match. Timestamps are Unix + /// seconds; a nil pointer means "no value". + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub starred: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub starred_at: Option, + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub rating: i32, + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub play_count: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub play_date: Option, + /// Composite + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tags: std::collections::HashMap>, + /// Participants lists the track's artists across all roles, each tagged with its Role. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub participants: Vec, +} diff --git a/plugins/pdk/rust/nd-pdk/Cargo.toml b/plugins/pdk/rust/nd-pdk/Cargo.toml index 34fe9f032..8ca457914 100644 --- a/plugins/pdk/rust/nd-pdk/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["rlib"] [dependencies] nd-pdk-host = { path = "../nd-pdk-host" } nd-pdk-capabilities = { path = "../nd-pdk-capabilities" } +nd-pdk-types = { path = "../nd-pdk-types" } extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk/src/lib.rs b/plugins/pdk/rust/nd-pdk/src/lib.rs index b1389938b..36b2ec316 100644 --- a/plugins/pdk/rust/nd-pdk/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk/src/lib.rs @@ -31,5 +31,8 @@ pub use nd_pdk_host as host; /// Capability wrappers for implementing plugin exports. pub use nd_pdk_capabilities::*; +/// Shared plugin data types. +pub use nd_pdk_types as types; + /// Re-export extism-pdk for convenience. pub use extism_pdk; diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 8abdccf07..b3203a352 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" ) // CapabilityScrobbler indicates the plugin can receive scrobble events. @@ -154,10 +155,10 @@ func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo } // participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef -func participantsToArtistRefs(participants model.ParticipantList) []capabilities.ArtistRef { - refs := make([]capabilities.ArtistRef, len(participants)) +func participantsToArtistRefs(participants model.ParticipantList) []types.ArtistRef { + refs := make([]types.ArtistRef, len(participants)) for i, p := range participants { - refs[i] = capabilities.ArtistRef{ + refs[i] = types.ArtistRef{ ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go index ff399680e..a4f7d73f8 100644 --- a/plugins/sonic_similarity_adapter.go +++ b/plugins/sonic_similarity_adapter.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" ) const CapabilitySonicSimilarity Capability = "SonicSimilarity" @@ -61,8 +62,8 @@ func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endM return sonicMatchesToSimilarResults(resp.Matches), nil } -func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { - ref := capabilities.SongRef{ +func mediaFileToSongRef(mf *model.MediaFile) types.SongRef { + ref := types.SongRef{ ID: mf.ID, Name: mf.Title, MBID: mf.MbzRecordingID, @@ -70,7 +71,10 @@ func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { ArtistMBID: mf.MbzArtistID, Album: mf.Album, AlbumMBID: mf.MbzAlbumID, - Duration: mf.Duration, + } + ref.SetDuration(mf.Duration) + for _, p := range mf.Participants[model.RoleArtist] { + ref.Artists = append(ref.Artists, types.ArtistRef{ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, SortName: p.SortArtistName, Role: model.RoleArtist.String()}) } if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 { ref.ISRC = isrcs[0] diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index daea04761..cb08b9acd 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -5,6 +5,7 @@ package plugins import ( "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -42,7 +43,8 @@ var _ = Describe("SonicSimilarityPlugin", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1")) - Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Song.Artists).To(HaveLen(1)) + Expect(results[0].Song.Artists[0].Name).To(Equal("The Beatles")) Expect(results[0].Similarity).To(Equal(1.0)) Expect(results[1].Similarity).To(Equal(0.9)) Expect(results[2].Similarity).To(Equal(0.8)) @@ -67,7 +69,8 @@ var _ = Describe("SonicSimilarityPlugin", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1")) - Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Song.Artists).To(HaveLen(1)) + Expect(results[0].Song.Artists[0].Name).To(Equal("The Beatles")) Expect(results[0].Similarity).To(Equal(1.0)) Expect(results[1].Similarity).To(Equal(0.95)) Expect(results[2].Similarity).To(Equal(0.9)) @@ -108,3 +111,24 @@ var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() { Expect(err.Error()).To(ContainSubstring("simulated plugin error")) }) }) + +var _ = Describe("mediaFileToSongRef multi-artist", func() { + It("fills Artists (with IDs) from role=artist participants", func() { + mf := &model.MediaFile{ID: "x", Title: "Collab", Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "ar-drake", Name: "Drake", MbzArtistID: "m-drake"}}, + {Artist: model.Artist{ID: "ar-future", Name: "Future", MbzArtistID: "m-future"}}, + }, + }} + ref := mediaFileToSongRef(mf) + Expect(ref.Artists).To(Equal([]capabilities.ArtistRef{ + {ID: "ar-drake", Name: "Drake", MBID: "m-drake", Role: "artist"}, + {ID: "ar-future", Name: "Future", MBID: "m-future", Role: "artist"}, + })) + }) + It("leaves Artists nil when the track has no role=artist participants", func() { + mf := &model.MediaFile{ID: "x", Title: "Solo", Artist: "Drake"} + ref := mediaFileToSongRef(mf) + Expect(ref.Artists).To(BeNil()) + }) +}) diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 0e485ceba..2ee2dabbf 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -15,12 +15,47 @@ func init() { type testLyrics struct{} func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) { - // Check for configured error errMsg, hasErr := pdk.GetConfig("error") if hasErr && errMsg != "" { return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg) } + // Config-selected format lets tests exercise the adapter's content-sniffing per format. + format, hasFormat := pdk.GetConfig("format") + if hasFormat { + var text string + var lang string + switch format { + case "ttml": + lang = "eng" + text = ` + + +
+

plugin ttml line

+
+ +
` + case "srt": + lang = "eng" + text = "1\n00:00:01,000 --> 00:00:02,000\nplugin srt line\n" + case "yaml": + lang = "eng" + text = "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: \"plugin yaml line\"\n start_ms: 1000\n" + case "lrc": + lang = "eng" + text = "[00:01.00]plugin lrc line" + case "plain": + lang = "eng" + text = "plugin plain line" + } + if text != "" { + return lyrics.GetLyricsResponse{ + Lyrics: []lyrics.LyricsText{{Lang: lang, Text: text}}, + }, nil + } + } + // Check if we should omit language (to test default language handling) noLang, hasNoLang := pdk.GetConfig("no_lang") lang := "eng" diff --git a/plugins/testdata/test-matcher/go.mod b/plugins/testdata/test-matcher/go.mod new file mode 100644 index 000000000..7abbc93c6 --- /dev/null +++ b/plugins/testdata/test-matcher/go.mod @@ -0,0 +1,16 @@ +module test-matcher + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-matcher/go.sum b/plugins/testdata/test-matcher/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-matcher/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-matcher/main.go b/plugins/testdata/test-matcher/main.go new file mode 100644 index 000000000..80d650b7b --- /dev/null +++ b/plugins/testdata/test-matcher/main.go @@ -0,0 +1,56 @@ +// Test Matcher plugin for Navidrome plugin system integration tests. +// Build with: tinygo build -o ../test-matcher.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" +) + +// TestMatcherInput is the input for the nd_test_matcher callback. +type TestMatcherInput struct { + Songs []types.SongRef `json:"songs"` + Username string `json:"username,omitempty"` +} + +// TestMatcherOutput is the output from the nd_test_matcher callback. +// MatchedIDs and Starred are aligned to the input: an empty string at index i +// means no match; Starred[i] reflects the matched track's starred flag. +type TestMatcherOutput struct { + MatchedIDs []string `json:"matched_ids"` + Starred []bool `json:"starred"` + Error *string `json:"error,omitempty"` +} + +// nd_test_matcher forwards the input song list to the host matcher and returns matched track IDs. +// +//go:wasmexport nd_test_matcher +func ndTestMatcher() int32 { + var input TestMatcherInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) + return 0 + } + + results, err := host.MatcherMatchSongs(input.Songs, host.MatchOptions{Username: input.Username}) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) + return 0 + } + + ids := make([]string, len(results)) + starred := make([]bool, len(results)) + for i, t := range results { + if t != nil { + ids[i] = t.ID + starred[i] = t.Starred + } + } + pdk.OutputJSON(TestMatcherOutput{MatchedIDs: ids, Starred: starred}) + return 0 +} + +func main() {} diff --git a/plugins/testdata/test-matcher/manifest.json b/plugins/testdata/test-matcher/manifest.json new file mode 100644 index 000000000..26be5c577 --- /dev/null +++ b/plugins/testdata/test-matcher/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Test Matcher Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for Matcher integration testing", + "permissions": { + "matcher": { + "reason": "For testing matcher operations" + }, + "library": { + "reason": "Matching returns library tracks" + } + } +} diff --git a/plugins/testdata/test-metadata-agent/main.go b/plugins/testdata/test-metadata-agent/main.go index 23e933eb3..bb8a092c6 100644 --- a/plugins/testdata/test-metadata-agent/main.go +++ b/plugins/testdata/test-metadata-agent/main.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/metadata" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) func init() { @@ -67,9 +68,9 @@ func (t *testMetadataAgent) GetSimilarArtists(input metadata.SimilarArtistsReque if limit == 0 { limit = 5 } - artists := make([]metadata.ArtistRef, 0, limit) + artists := make([]types.ArtistRef, 0, limit) for i := range limit { - artists = append(artists, metadata.ArtistRef{ + artists = append(artists, types.ArtistRef{ ID: "similar-artist-id-" + strconv.Itoa(i+1), Name: input.Name + " Similar " + string(rune('A'+i)), MBID: "similar-mbid-" + strconv.Itoa(i+1), @@ -86,9 +87,9 @@ func (t *testMetadataAgent) GetArtistTopSongs(input metadata.TopSongsRequest) (* if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "song-id-" + strconv.Itoa(i+1), Name: input.Name + " Song " + strconv.Itoa(i+1), MBID: "song-mbid-" + strconv.Itoa(i+1), @@ -128,9 +129,9 @@ func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "similar-track-id-" + strconv.Itoa(i+1), Name: "Similar to " + input.Name + " #" + strconv.Itoa(i+1), MBID: "similar-mbid-" + strconv.Itoa(i+1), @@ -150,9 +151,9 @@ func (t *testMetadataAgent) GetSimilarSongsByAlbum(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "album-similar-id-" + strconv.Itoa(i+1), Name: "Album Similar #" + strconv.Itoa(i+1), Artist: input.Artist, @@ -170,9 +171,9 @@ func (t *testMetadataAgent) GetSimilarSongsByArtist(input metadata.SimilarSongsB if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "artist-similar-id-" + strconv.Itoa(i+1), Name: input.Name + " Style Song #" + strconv.Itoa(i+1), Artist: input.Name + " Similar Artist", diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go index e315bff15..538e9c8f3 100644 --- a/plugins/testdata/test-sonic-similarity/main.go +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) func init() { @@ -35,7 +36,7 @@ func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSon matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: sonicsimilarity.SongRef{ + Song: types.SongRef{ ID: "similar-track-" + strconv.Itoa(i+1), Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1), Artist: input.Song.Artist, @@ -57,7 +58,7 @@ func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathR matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: sonicsimilarity.SongRef{ + Song: types.SongRef{ ID: "path-track-" + strconv.Itoa(i+1), Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1), Artist: input.StartSong.Artist, diff --git a/plugins/types/track.go b/plugins/types/track.go new file mode 100644 index 000000000..9b63c0f26 --- /dev/null +++ b/plugins/types/track.go @@ -0,0 +1,93 @@ +package types + +// Track is a stable, public projection of a library media file for plugin consumption. +// It is a sane subset of the internal model.MediaFile, intended for reuse across host +// services and capabilities. Timestamps are Unix epoch seconds. +// +// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +// Track is a concrete library entity: it identifies a specific media file that exists +// (or once existed) in the library and exposes its full descriptive metadata. +type Track struct { + // Identity & location + ID string `json:"id"` + LibraryID int32 `json:"libraryId"` + LibraryName string `json:"libraryName,omitempty"` + Path string `json:"path,omitempty"` + Missing bool `json:"missing"` + + // Core metadata + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumArtist string `json:"albumArtist,omitempty"` + AlbumID string `json:"albumId,omitempty"` + SortTitle string `json:"sortTitle,omitempty"` + SortAlbumName string `json:"sortAlbumName,omitempty"` + SortArtistName string `json:"sortArtistName,omitempty"` + + // Track / disc / dates + TrackNumber int32 `json:"trackNumber"` + DiscNumber int32 `json:"discNumber"` + DiscSubtitle string `json:"discSubtitle,omitempty"` + Year int32 `json:"year"` + Date string `json:"date,omitempty"` + OriginalYear int32 `json:"originalYear"` + OriginalDate string `json:"originalDate,omitempty"` + ReleaseYear int32 `json:"releaseYear"` + ReleaseDate string `json:"releaseDate,omitempty"` + + // Audio / file + Size int64 `json:"size"` + Suffix string `json:"suffix,omitempty"` + Duration float64 `json:"duration"` + BitRate int32 `json:"bitRate"` + SampleRate int32 `json:"sampleRate"` + BitDepth *int32 `json:"bitDepth,omitempty"` + Channels int32 `json:"channels"` + Codec string `json:"codec,omitempty"` + + // Descriptive + Genres []string `json:"genres,omitempty"` + Comment string `json:"comment,omitempty"` + BPM *int32 `json:"bpm,omitempty"` + ExplicitStatus string `json:"explicitStatus,omitempty"` + CatalogNum string `json:"catalogNum,omitempty"` + Compilation bool `json:"compilation"` + HasCoverArt bool `json:"hasCoverArt"` + + // MusicBrainz + MbzRecordingID string `json:"mbzRecordingId,omitempty"` + MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + MbzAlbumType string `json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` + + // ReplayGain — nil means no data; 0 is a valid measured value, so these + // must stay pointers to distinguish "absent" from "0". + RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` + RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` + RGTrackGain *float64 `json:"rgTrackGain,omitempty"` + RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` + + // Timestamps (Unix epoch seconds) + BirthTime int64 `json:"birthTime"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + + // AverageRating is the track's mean rating across all users (always set; 0 when unrated). + AverageRating float64 `json:"averageRating"` + + // Per-user annotations, set only for a user-scoped match. Timestamps are Unix + // seconds; a nil pointer means "no value". + Starred bool `json:"starred,omitempty"` + StarredAt *int64 `json:"starredAt,omitempty"` + Rating int32 `json:"rating,omitempty"` + PlayCount int64 `json:"playCount,omitempty"` + PlayDate *int64 `json:"playDate,omitempty"` + + // Composite + Tags map[string][]string `json:"tags,omitempty"` + // Participants lists the track's artists across all roles, each tagged with its Role. + Participants []ArtistRef `json:"participants,omitempty"` +} diff --git a/plugins/types/types.go b/plugins/types/types.go new file mode 100644 index 000000000..71928a924 --- /dev/null +++ b/plugins/types/types.go @@ -0,0 +1,87 @@ +package types + +// ArtistRef is the minimal information a plugin returns for Navidrome to match an +// artist against the library. It is a reference, not a full artist entity: it +// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +// few projection fields used when describing a track's participants, never +// descriptive data such as biographies or images. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` + // SortName is the artist name used for sorting (if known). + SortName string `json:"sortName,omitempty"` + // Role is the participation category (e.g. "artist", "composer", "performer"). + Role string `json:"role,omitempty"` + // SubRole is a specialization within Role (e.g. the instrument for a performer). + SubRole string `json:"subRole,omitempty"` +} + +// SongRef is the minimal information exchanged between a plugin and Navidrome to +// match a song. It is used both as input (a song Navidrome already has) and as +// output (a song a plugin suggests, which may not be in the library yet). Unlike +// Track, it is an abstract recording reference carrying only matching keys (IDs, +// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` // TODO: rename to Title to align with Track.Title and model.MediaFile.Title; kept as Name for now for compatibility. + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + // + // Deprecated: use Artists. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + // + // Deprecated: use Artists. + ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + // + // Deprecated: use DurationMs, which carries millisecond precision. When + // DurationMs is non-zero it takes precedence; Duration is kept only for + // backwards compatibility with plugins that still send seconds. + Duration float32 `json:"duration,omitempty"` + // DurationMs is the song duration in milliseconds. It supersedes Duration + // when non-zero. + DurationMs uint32 `json:"durationMs,omitempty"` +} + +// DurationInMs returns the song duration in milliseconds, preferring the +// millisecond-precision DurationMs and falling back to the deprecated +// seconds-based Duration. It returns 0 when neither is set, and clamps a +// negative seconds value to 0 to avoid an unsigned-conversion wraparound. +func (s SongRef) DurationInMs() uint32 { + if s.DurationMs != 0 { + return s.DurationMs + } + if s.Duration < 0 { + return 0 + } + return uint32(s.Duration * 1000) +} + +// SetDuration sets the song duration from a value in seconds, populating both the +// millisecond-precision DurationMs and the deprecated seconds-based Duration so +// that plugins reading either field see a consistent value. Use this when +// building a SongRef to send to a plugin. +func (s *SongRef) SetDuration(seconds float32) { + s.Duration = seconds + if seconds < 0 { + s.DurationMs = 0 + return + } + s.DurationMs = uint32(seconds * 1000) +} diff --git a/plugins/types/types_suite_test.go b/plugins/types/types_suite_test.go new file mode 100644 index 000000000..9c058ec4f --- /dev/null +++ b/plugins/types/types_suite_test.go @@ -0,0 +1,17 @@ +package types_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTypes(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Plugins Types Suite") +} diff --git a/plugins/types/types_test.go b/plugins/types/types_test.go new file mode 100644 index 000000000..ae6d40a25 --- /dev/null +++ b/plugins/types/types_test.go @@ -0,0 +1,50 @@ +package types_test + +import ( + "github.com/navidrome/navidrome/plugins/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SongRef", func() { + Describe("DurationInMs", func() { + It("returns DurationMs when set", func() { + s := types.SongRef{DurationMs: 247333, Duration: 247.5} + Expect(s.DurationInMs()).To(Equal(uint32(247333))) + }) + + It("falls back to Duration (seconds) when DurationMs is zero", func() { + s := types.SongRef{Duration: 247.5} + Expect(s.DurationInMs()).To(Equal(uint32(247500))) + }) + + It("returns 0 when neither is set", func() { + Expect(types.SongRef{}.DurationInMs()).To(BeZero()) + }) + + It("clamps a negative seconds value to 0 instead of overflowing", func() { + Expect(types.SongRef{Duration: -1}.DurationInMs()).To(BeZero()) + }) + }) + + Describe("SetDuration", func() { + It("populates both DurationMs and the deprecated Duration from seconds", func() { + var s types.SongRef + s.SetDuration(247.333) + Expect(s.Duration).To(BeNumerically("~", 247.333, 0.001)) + Expect(s.DurationMs).To(Equal(uint32(247333))) + }) + + It("keeps DurationInMs consistent with what was set", func() { + var s types.SongRef + s.SetDuration(60) + Expect(s.DurationInMs()).To(Equal(uint32(60000))) + }) + + It("clamps a negative duration to a zero DurationMs", func() { + var s types.SongRef + s.SetDuration(-1) + Expect(s.DurationMs).To(BeZero()) + }) + }) +}) diff --git a/resources/mappings.yaml b/resources/mappings.yaml index 16dddd504..294654b6a 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -110,7 +110,9 @@ main: lyrics: # Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx` aliases: [ uslt:description, lyrics, unsyncedlyrics ] - maxLength: 32768 + # Generous cap to fit word-timed TTML/Enhanced-LRC karaoke for a full song, + # while still bounding against pathological tags. + maxLength: 1048576 type: pair # ex: lyrics:eng, lyrics:xxx comment: aliases: [ comm:description, comment, ©cmt, description, icmt ] diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go index 3ccbd8961..8cde586b9 100644 --- a/scanner/metadata_old/metadata.go +++ b/scanner/metadata_old/metadata.go @@ -205,13 +205,14 @@ func (t Tags) Lyrics() string { basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") for _, value := range basicLyrics { - lyrics, err := model.ToLyrics("xxx", value) + parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(value)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } for tag, value := range t.Tags { @@ -223,13 +224,14 @@ func (t Tags) Lyrics() string { } for _, text := range value { - lyrics, err := model.ToLyrics(language, text) + parsed, err := model.ParseLyrics(".lrc", language, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } } } diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index e6a694f2b..55bbab684 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -123,9 +123,6 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath) continue } - if isEntryIgnored(entry.Name()) { - continue - } if ctx.Err() != nil { return folder, children, ctx.Err() } @@ -135,7 +132,10 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err) continue } - if isDir && !isDirIgnored(entry.Name()) && isDirReadable(ctx, job.fs, entryPath) { + if isIgnoredEntry(entry.Name(), isDir) { + continue + } + if isDir && isDirReadable(ctx, job.fs, entryPath) { children = append(children, entryPath) folder.numSubFolders++ } else { @@ -147,12 +147,16 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC if fileInfo.ModTime().After(folder.modTime) { folder.modTime = fileInfo.ModTime() } + name, ok := resolveEntryName(ctx, job.fs, dirPath, entry) + if !ok { + continue + } switch { - case model.IsAudioFile(entry.Name()): + case model.IsAudioFile(name): folder.audioFiles[entry.Name()] = entry - case model.IsValidPlaylist(entry.Name()): + case model.IsValidPlaylist(name): folder.numPlaylists++ - case model.IsImageFile(entry.Name()): + case model.IsImageFile(name): folder.imageFiles[entry.Name()] = entry folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) } @@ -213,6 +217,45 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool, return fileInfo.IsDir(), nil } +const maxSymlinkHops = 40 + +// resolveEntryName returns the name to classify the entry by, and whether to +// consider it at all. Symlinks are resolved to their final target so the caller +// classifies by the target's extension, not the link's name. Returns ok=false +// when symlinks are disabled or the target can't be resolved. +func resolveEntryName(ctx context.Context, fsys fs.FS, dirPath string, entry fs.DirEntry) (string, bool) { + if entry.Type()&fs.ModeSymlink == 0 { + return entry.Name(), true + } + linkPath := path.Join(dirPath, entry.Name()) + if !conf.Server.Scanner.FollowSymlinks { + log.Trace(ctx, "Scanner: Skipping symlink, following is disabled", "path", linkPath) + return "", false + } + cur := linkPath + for hop := 0; hop < maxSymlinkHops; hop++ { + target, err := fs.ReadLink(fsys, cur) + if err != nil { + if hop == 0 { + log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err) + return "", false + } + resolved := path.Base(cur) + log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", cur, "name", resolved) + return resolved, true + } + if path.IsAbs(target) { + // Absolute targets are not valid fs.FS paths, so the next ReadLink fails and + // resolution stops here, leaving cur as the target to classify by name. + cur = target + } else { + cur = path.Join(path.Dir(cur), target) + } + } + log.Trace(ctx, "Scanner: Skipping symlink, too many hops (possible loop)", "path", linkPath) + return "", false +} + // isDirReadable returns true if the directory represented by dirEnt is readable func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool { dir, err := fsys.Open(dirPath) @@ -233,22 +276,35 @@ var ignoredDirs = []string{ "#snapshot", "@Recycle", "@Recently-Snapshot", + ".git", ".streams", "lost+found", } -// isDirIgnored returns true if the directory represented by dirEnt should be ignored -func isDirIgnored(name string) bool { - // allows Album folders for albums which eg start with ellipses - if strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") { +// isIgnoredEntry returns true if a directory entry with the given name should be +// skipped during scanning. It centralizes all name- and type-based ignore policy: +// - special system directories in ignoredDirs are always ignored; +// - dot-prefixed files are always ignored; +// - dot-prefixed folders are ignored unless Scanner.IgnoreDotFolders is disabled, +// allowing albums like ".Hack Sign" to be scanned when the option is off. +func isIgnoredEntry(name string, isDir bool) bool { + if isDir && isDirIgnored(name) { return true } - if slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) { - return true - } - return false + return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders) } -func isEntryIgnored(name string) bool { - return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") +// isDirIgnored returns true if the directory name is in the explicit ignoredDirs +// blocklist. Used both while walking the tree and by the file watcher. +func isDirIgnored(name string) bool { + return slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) +} + +// isDotEntry returns true only for names with exactly one leading dot (the +// convention for hidden entries), e.g. ".hidden". Names with two or more leading +// dots are not considered hidden: "." and ".." are the special self/parent +// references, and anything like "..foo" or "...Album" is a regular name (album +// folders sometimes start with ellipses), so all of these return false. +func isDotEntry(name string) bool { + return name != "." && strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") } diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 42b7af7ba..f3b13a4ef 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -45,6 +45,14 @@ var _ = Describe("walk_dir_tree", func() { "root/d/f3.mp3": {}, "root/e/original/f1.mp3": {}, "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")}, + "root/f/realsong.mp3": {Data: []byte("AUDIO")}, + "root/f/legit.mp3": {Mode: fs.ModeSymlink, Data: []byte("realsong.mp3")}, + "root/f/secret": {Data: []byte("TOPSECRET")}, + "root/f/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("secret")}, + "root/g/.Hack Sign Original Soundtrack/track.mp3": {}, + "root/h/.hidden.mp3": {}, + "root/i/.git/config": {}, + "root/i/.streams/stream.mp3": {}, }, } job = &scanJob{ @@ -93,15 +101,52 @@ var _ = Describe("walk_dir_tree", func() { Expect(folders["root/c"].imageFiles).To(BeEmpty()) Expect(folders).ToNot(HaveKey("root/d")) + // By default (Scanner.IgnoreDotFolders == true), dot-prefixed + // folders are skipped, dot-prefixed files are not indexed, and + // the special ignoredDirs (.git, .streams) are never traversed. + Expect(folders).ToNot(HaveKey("root/g/.Hack Sign Original Soundtrack")) + Expect(folders["root/h"].audioFiles).To(BeEmpty()) + Expect(folders).ToNot(HaveKey("root/i/.git")) + Expect(folders).ToNot(HaveKey("root/i/.streams")) + // Symlink specific checks if followSymlinks { Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1)) + Expect(folders["root/f"].audioFiles).To(HaveKey("legit.mp3")) + Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } else { Expect(folders).ToNot(HaveKey("root/e/symlink")) + Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("legit.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } }, - Entry("with symlinks enabled", true, 7), - Entry("with symlinks disabled", false, 6), + Entry("with symlinks enabled", true, 11), + Entry("with symlinks disabled", false, 10), + ) + + DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled", + func(followSymlinks bool) { + conf.Server.Scanner.FollowSymlinks = followSymlinks + conf.Server.Scanner.IgnoreDotFolders = false + folders := getFolders() + + // Dot-prefixed album folders are now traversed and indexed + Expect(folders["root/g/.Hack Sign Original Soundtrack"].audioFiles).To(SatisfyAll( + HaveLen(1), + HaveKey("track.mp3"), + )) + + // Dot-prefixed files are still ignored, even with the flag off + Expect(folders["root/h"].audioFiles).To(BeEmpty()) + + // Special ignoredDirs remain blocked regardless of the flag + Expect(folders).ToNot(HaveKey("root/i/.git")) + Expect(folders).ToNot(HaveKey("root/i/.streams")) + }, + Entry("with symlinks enabled", true), + Entry("with symlinks disabled", false), ) }) @@ -264,19 +309,237 @@ var _ = Describe("walk_dir_tree", func() { }) }) + Describe("resolveEntryName", func() { + var fsys fs.FS + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + fsys = fstest.MapFS{ + "dir/real.mp3": {Data: []byte("AUDIO")}, + "dir/mid.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, + "dir/chain.mp3": {Mode: fs.ModeSymlink, Data: []byte("mid.mp3")}, + "dir/audio.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, + "dir/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("../outside/passwd")}, + "dir/loop1.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop2.mp3")}, + "dir/loop2.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop1.mp3")}, + "dir/dangle.mp3": {Mode: fs.ModeSymlink, Data: []byte("missing.mp3")}, + } + }) + + resolve := func(name string) (string, bool) { + entries, err := fs.ReadDir(fsys, "dir") + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + if e.Name() == name { + return resolveEntryName(GinkgoT().Context(), fsys, "dir", e) + } + } + Fail("entry not found: " + name) + return "", false + } + + Context("with symlinks enabled", func() { + BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = true }) + + It("returns the entry name for a plain file", func() { + name, ok := resolve("real.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a direct symlink to its audio target name", func() { + name, ok := resolve("audio.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a symlink CHAIN to the final target name", func() { + name, ok := resolve("chain.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a symlink to a non-audio target name (so caller can reject it)", func() { + name, ok := resolve("evil.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("passwd")) + }) + It("rejects a symlink loop", func() { + _, ok := resolve("loop1.mp3") + Expect(ok).To(BeFalse()) + }) + }) + + Context("with symlinks disabled", func() { + BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = false }) + + It("returns the entry name for a plain file", func() { + name, ok := resolve("real.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("skips any file symlink", func() { + _, ok := resolve("audio.mp3") + Expect(ok).To(BeFalse()) + }) + }) + }) + + Describe("symlink chain (real fs)", func() { + BeforeEach(func() { + tests.SkipOnWindows("symlink semantics") + DeferCleanup(configtest.SetupConfig()) + }) + + classify := func(fsys fs.FS, dirPath, name string) (string, bool) { + entries, err := fs.ReadDir(fsys, dirPath) + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + if e.Name() == name { + return resolveEntryName(GinkgoT().Context(), fsys, dirPath, e) + } + } + Fail("entry not found: " + name) + return "", false + } + + Context("committed 3-level fixtures", func() { + // tests.Init chdirs to the repo root, so the committed fixtures are at "tests/fixtures". + var fsys fs.FS + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + wd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + fsys = os.DirFS(wd) + }) + + It("keeps a 3-level chain that resolves to real audio", func() { + name, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("test.mp3")) + Expect(model.IsAudioFile(name)).To(BeTrue()) + }) + + It("rejects a 3-level chain that resolves to a non-audio file", func() { + name, ok := classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("index.html")) + Expect(model.IsAudioFile(name)).To(BeFalse()) + }) + + It("skips the chain entirely when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + _, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") + Expect(ok).To(BeFalse()) + _, ok = classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") + Expect(ok).To(BeFalse()) + }) + }) + + Context("out-of-tree escape (temp dir)", func() { + var root string + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + root = GinkgoT().TempDir() + outside := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(outside, "passwd"), []byte("TOPSECRET"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(outside, "real.flac"), []byte("AUDIO"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(root, "song.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) + // evil.mp3 escapes to a non-audio target; legit.flac is a valid out-of-tree audio symlink. + Expect(os.Symlink(filepath.Join(outside, "passwd"), filepath.Join(root, "evil.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(outside, "real.flac"), filepath.Join(root, "legit.flac"))).To(Succeed()) + }) + + It("rejects the absolute-path escape but keeps legit out-of-tree audio", func() { + fsys := os.DirFS(root) + + name, ok := classify(fsys, ".", "song.mp3") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeTrue()) + + name, ok = classify(fsys, ".", "legit.flac") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeTrue()) + + name, ok = classify(fsys, ".", "evil.mp3") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeFalse()) + }) + + It("skips all file symlinks when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + fsys := os.DirFS(root) + entries, err := fs.ReadDir(fsys, ".") + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + _, ok := resolveEntryName(GinkgoT().Context(), fsys, ".", e) + if e.Type()&fs.ModeSymlink != 0 { + Expect(ok).To(BeFalse(), e.Name()) + } else { + Expect(ok).To(BeTrue(), e.Name()) + } + } + }) + }) + }) + Describe("isDirIgnored", func() { DescribeTable("returns expected result", func(dirName string, expected bool) { Expect(isDirIgnored(dirName)).To(Equal(expected)) }, Entry("normal dir", "empty_folder", false), - Entry("hidden dir", ".hidden_folder", true), + Entry("dot-prefixed album dir", ".Hack Sign Original Soundtrack", false), + Entry("git dir", ".git", true), + Entry("streams dir", ".streams", true), Entry("dir starting with ellipsis", "...unhidden_folder", false), Entry("recycle bin", "$Recycle.Bin", true), Entry("snapshot dir", "#snapshot", true), ) }) + Describe("isIgnoredEntry", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + DescribeTable("with IgnoreDotFolders enabled (default)", + func(name string, isDir, expected bool) { + conf.Server.Scanner.IgnoreDotFolders = true + Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) + }, + Entry("normal dir", "Album", true, false), + Entry("normal file", "track.mp3", false, false), + Entry("dot folder", ".Hack Sign Original Soundtrack", true, true), + Entry("dot file", ".hidden.mp3", false, true), + Entry("blocklisted dir", ".git", true, true), + Entry("ellipsis dir", "...unhidden", true, false), + ) + + DescribeTable("with IgnoreDotFolders disabled", + func(name string, isDir, expected bool) { + conf.Server.Scanner.IgnoreDotFolders = false + Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) + }, + Entry("normal dir", "Album", true, false), + Entry("normal file", "track.mp3", false, false), + Entry("dot folder is allowed", ".Hack Sign Original Soundtrack", true, false), + Entry("dot file is still ignored", ".hidden.mp3", false, true), + Entry("blocklisted dir still ignored", ".git", true, true), + ) + }) + + Describe("isDotEntry", func() { + DescribeTable("returns expected result", + func(name string, expected bool) { + Expect(isDotEntry(name)).To(Equal(expected)) + }, + Entry("dot folder", ".Hidden", true), + Entry("dot file", ".hidden.mp3", true), + Entry("current dir", ".", false), + Entry("parent dir", "..", false), + Entry("two leading dots", "..foo", false), + Entry("ellipsis", "...unhidden", false), + Entry("normal name", "Album", false), + ) + }) + Describe("fullReadDir", func() { var ( fsys fakeFS @@ -414,3 +677,30 @@ func (m *mockMusicFS) ReadDir(name string) ([]fs.DirEntry, error) { } return nil, fmt.Errorf("not a directory") } + +// ReadLink returns the target of the named symbolic link (implements fs.ReadLinkFS). +func (m *mockMusicFS) ReadLink(name string) (string, error) { + mapFS := m.FS.(fstest.MapFS) + entry, ok := mapFS[name] + if !ok { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrNotExist} + } + if entry.Mode&fs.ModeSymlink == 0 { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fmt.Errorf("not a symlink")} + } + return string(entry.Data), nil +} + +// Lstat returns FileInfo for the named file without following symlinks (implements fs.ReadLinkFS). +func (m *mockMusicFS) Lstat(name string) (fs.FileInfo, error) { + mapFS := m.FS.(fstest.MapFS) + if _, ok := mapFS[name]; !ok { + return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist} + } + f, err := m.FS.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return f.Stat() +} diff --git a/scanner/watcher.go b/scanner/watcher.go index 376db910c..baf94b79b 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "path/filepath" + "strings" "sync" "time" @@ -320,18 +321,35 @@ func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.Music } func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool { - baseDir, name := filepath.Split(path) + _, name := filepath.Split(path) + // A change anywhere inside an ignored directory (a dot-folder when + // Scanner.IgnoreDotFolders is enabled, or a special system folder) must not + // trigger a scan, even for media files: the scan would skip it anyway. + if isUnderIgnoredDir(path) { + return true + } switch { - case model.IsAudioFile(path): - return false - case model.IsValidPlaylist(path): - return false - case model.IsImageFile(path): - return false + case model.IsAudioFile(path), model.IsValidPlaylist(path), model.IsImageFile(path): + // A media file is normally not ignored, but a dot-prefixed one (e.g. + // ".hidden.mp3") is always skipped by the scanner, so don't scan for it. + return isDotEntry(name) case name == ".DS_Store": return true } - // As it can be a deletion and not a change, we cannot reliably know if the path is a file or directory. - // But at this point, we can assume it's a directory. If it's a file, it would be ignored anyway - return isDirIgnored(baseDir) + // As it can be a deletion and not a change, we cannot reliably know if the + // path is a file or directory. But at this point, we can assume it's a + // directory. If it's a file, it would be ignored anyway. + return isIgnoredEntry(name, true) +} + +// isUnderIgnoredDir returns true if any parent directory component of the given +// path is an ignored directory, reusing the same policy as the scanner walk. +func isUnderIgnoredDir(path string) bool { + dir, _ := filepath.Split(path) + for part := range strings.SplitSeq(filepath.ToSlash(dir), "/") { + if part != "" && isIgnoredEntry(part, true) { + return true + } + } + return false } diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 9795129b0..ffe9f8b15 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -428,6 +428,50 @@ var _ = Describe("Watcher", func() { Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder") }) }) + + }) +}) + +var _ = Describe("isIgnoredPath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + Context("with IgnoreDotFolders enabled (default)", func() { + BeforeEach(func() { + conf.Server.Scanner.IgnoreDotFolders = true + }) + + DescribeTable("returns expected result", + func(p string, expected bool) { + Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) + }, + Entry("media file in normal folder", "rock/Album/track.mp3", false), + Entry("dot-prefixed media file", "rock/Album/.hidden.mp3", true), + Entry("media file inside a dot-folder", "rock/.Hidden Album/track.mp3", true), + Entry("media file inside a blocklisted folder", "rock/.streams/stream.mp3", true), + Entry("media file inside .git", "rock/.git/track.mp3", true), + Entry("dot-folder itself", "rock/.Hidden Album", true), + Entry("normal folder itself", "rock/Album", false), + Entry(".DS_Store file", "rock/Album/.DS_Store", true), + ) + }) + + Context("with IgnoreDotFolders disabled", func() { + BeforeEach(func() { + conf.Server.Scanner.IgnoreDotFolders = false + }) + + DescribeTable("returns expected result", + func(p string, expected bool) { + Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) + }, + Entry("media file inside a dot-folder is allowed", "rock/.Hidden Album/track.mp3", false), + Entry("dot-prefixed media file is still ignored", "rock/Album/.hidden.mp3", true), + Entry("dot-folder itself is allowed", "rock/.Hidden Album", false), + Entry("blocklisted folder still ignored", "rock/.streams/stream.mp3", true), + Entry(".git still ignored", "rock/.git/config", true), + ) }) }) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 12a7c95e0..ac4aaa5f2 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -70,6 +70,20 @@ const ( mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id ) +// lyricFixture reads a public-domain lyric fixture (the same files the parser +// benchmarks use) so the e2e fixtures stay in sync with real-world content, +// including the word-level timing carried by the .elrc and .yaml variants. +func lyricFixture(name string) string { + // tests.Init chdirs to the project root, so reference fixtures from there. + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "lyrics", name)) + Expect(err).ToNot(HaveOccurred(), "reading lyric fixture %q", name) + return string(data) +} + +// firstFixtureLine is the opening lyric line shared by every auld-lang-syne +// fixture; tests assert against it regardless of source format. +const firstFixtureLine = "Should auld acquaintance be forgot," + // Shared test state var ( ctx context.Context @@ -128,6 +142,9 @@ func buildTestFS() storagetest.FakeFS { // Template for diverse-format transcode test tracks tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"} + // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable + lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) + return createFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), @@ -177,6 +194,31 @@ func buildTestFS() storagetest.FakeFS { "bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180), }), + // Lyrics fixtures (isolated under Lyrics/ to keep other suite counts stable). + // Content comes from tests/fixtures/lyrics (the same public-domain files the + // parser benchmarks use); the .elrc and .yaml variants carry word-level + // timing, which drives the v1 (line-level) vs v2 (enhanced/word-level) tests. + // + // Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time. + // "Enhanced LRC" embeds ELRC (word-level) content; the title is kept generic + // since ELRC is still valid LRC. + "Lyrics/Embedded/01 - Embedded Enhanced LRC.mp3": lyricsAlbum(track(1, "Embedded Enhanced LRC", + _t{"lyrics": lyricFixture("auld-lang-syne.elrc")})), + "Lyrics/Embedded/02 - Embedded Plain.mp3": lyricsAlbum(track(2, "Embedded Plain", + _t{"lyrics": lyricFixture("auld-lang-syne.txt")})), + "Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML", + _t{"lyrics": lyricFixture("auld-lang-syne.ttml")})), + + // Sidecar — raw lyric text files read from the library FS at request time via fromExternalFile. + // The scanner skips non-audio extensions (.lrc, .srt, .yaml), so placing them as raw MapFile + // entries is safe: they are visible to the fake FS but invisible to the scanner. + "Lyrics/Sidecar/01 - Sidecar LRC.mp3": lyricsAlbum(track(1, "Sidecar LRC")), + "Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.lrc")), ModTime: time.Now()}, + "Lyrics/Sidecar/02 - Sidecar SRT.mp3": lyricsAlbum(track(2, "Sidecar SRT")), + "Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.srt")), ModTime: time.Now()}, + "Lyrics/Sidecar/03 - Sidecar YAML.mp3": lyricsAlbum(track(3, "Sidecar YAML")), + "Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.yaml")), ModTime: time.Now()}, + // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, }) @@ -409,6 +451,7 @@ var _ = BeforeSuite(func() { // Initial setup: schema, user, library, and full scan (runs once for the entire suite) conf.Server.MusicFolder = "fake:///music" + conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" conf.Server.DevExternalScanner = false db.Init(ctx) @@ -501,7 +544,7 @@ func setupTestDB() { core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), - lyrics.NewLyrics(nil), + lyrics.NewLyrics(ds, nil), decider, nil, ) diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go index d41d17dbc..6d32a3c88 100644 --- a/server/e2e/subsonic_album_lists_test.go +++ b/server/e2e/subsonic_album_lists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) }) It("type=alphabeticalByName sorts albums by name", func() { @@ -27,15 +27,16 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(7)) - // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop, Transcode Formats + Expect(albums).To(HaveLen(8)) + // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Lyrics, Pop, Transcode Formats Expect(albums[0].Title).To(Equal("Abbey Road")) Expect(albums[1].Title).To(Equal("COWBOY BEBOP")) Expect(albums[2].Title).To(Equal("Help!")) Expect(albums[3].Title).To(Equal("IV")) Expect(albums[4].Title).To(Equal("Kind of Blue")) - Expect(albums[5].Title).To(Equal("Pop")) - Expect(albums[6].Title).To(Equal("Transcode Formats")) + Expect(albums[5].Title).To(Equal("Lyrics")) + Expect(albums[6].Title).To(Equal("Pop")) + Expect(albums[7].Title).To(Equal("Transcode Formats")) }) It("type=alphabeticalByArtist sorts albums by artist name", func() { @@ -43,23 +44,24 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(7)) + Expect(albums).To(HaveLen(8)) // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" - // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ + // Non-compilations: Beatles (x2), Led Zeppelin, Lyric Tester, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ Expect(albums[0].Artist).To(Equal("The Beatles")) Expect(albums[1].Artist).To(Equal("The Beatles")) Expect(albums[2].Artist).To(Equal("Led Zeppelin")) - Expect(albums[3].Artist).To(Equal("Miles Davis")) - Expect(albums[4].Artist).To(Equal("Test Artist")) - Expect(albums[5].Artist).To(Equal("Various")) - Expect(albums[6].Artist).To(Equal("シートベルツ")) + Expect(albums[3].Artist).To(Equal("Lyric Tester")) + Expect(albums[4].Artist).To(Equal("Miles Davis")) + Expect(albums[5].Artist).To(Equal("Test Artist")) + Expect(albums[6].Artist).To(Equal("Various")) + Expect(albums[7].Artist).To(Equal("シートベルツ")) }) It("type=random returns albums", func() { resp := doReq("getAlbumList", "type", "random") Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) }) It("type=byGenre filters by genre parameter", func() { @@ -190,7 +192,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList2).ToNot(BeNil()) albums := resp.AlbumList2.Album - Expect(albums).To(HaveLen(7)) + Expect(albums).To(HaveLen(8)) // Verify AlbumID3 format fields Expect(albums[0].Name).To(Equal("Abbey Road")) Expect(albums[0].Id).ToNot(BeEmpty()) @@ -201,7 +203,7 @@ var _ = Describe("Album List Endpoints", func() { resp := doReq("getAlbumList2", "type", "newest") Expect(resp.AlbumList2).ToNot(BeNil()) - Expect(resp.AlbumList2.Album).To(HaveLen(7)) + Expect(resp.AlbumList2.Album).To(HaveLen(8)) }) }) diff --git a/server/e2e/subsonic_lyrics_test.go b/server/e2e/subsonic_lyrics_test.go new file mode 100644 index 000000000..da8d513a3 --- /dev/null +++ b/server/e2e/subsonic_lyrics_test.go @@ -0,0 +1,124 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Lyrics endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + // songID resolves a track title to its Subsonic ID via search3. + songID := func(title string) string { + resp := doReq("search3", "query", title, "songCount", "1", "artistCount", "0", "albumCount", "0") + Expect(resp.Status).To(Equal("ok")) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Song).ToNot(BeEmpty(), "expected to find song %q", title) + return resp.SearchResult3.Song[0].Id + } + + // firstLyric extracts the first StructuredLyric from a LyricsList response. + firstLyric := func(list *responses.LyricsList) responses.StructuredLyric { + Expect(list).ToNot(BeNil()) + Expect(list.StructuredLyrics).ToNot(BeEmpty()) + return list.StructuredLyrics[0] + } + + // songLyrics extension v1: getLyricsBySongId without the enhanced parameter + // returns line-level structured lyrics (line[], lang, synced) and must NOT + // emit any v2/enhanced fields — no cueLine, kind, or agents — even for + // formats that carry word-level timing (ELRC, Lyricsfile YAML). + Describe("getLyricsBySongId v1 (line-level, not enhanced)", func() { + DescribeTable("returns line-level lyrics without enhanced fields", + func(title string, wantSynced bool, wantLang string) { + resp := doReq("getLyricsBySongId", "id", songID(title)) + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Synced).To(Equal(wantSynced)) + Expect(got.Lang).To(Equal(wantLang)) + Expect(got.Line).ToNot(BeEmpty()) + Expect(got.Line[0].Value).To(Equal(firstFixtureLine)) + + // v1 must not expose any enhanced (v2) data. + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Kind).To(BeEmpty()) + Expect(got.Agents).To(BeEmpty()) + }, + // "xxx" is the ISO 639-2 code for "no language specified"; the .lrc/.elrc + // fixtures declare [lang:eng], the .ttml declares xml:lang, the .yaml sets + // language: eng, while .srt carries no language and the embedded plain + // text has none — so each format exercises a different language path. + Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true, "eng"), + Entry("embedded plain text", "Embedded Plain", false, "xxx"), + Entry("embedded TTML", "Embedded TTML", true, "eng"), + Entry("LRC sidecar", "Sidecar LRC", true, "eng"), + Entry("SRT sidecar", "Sidecar SRT", true, "xxx"), + Entry("YAML sidecar (word-level)", "Sidecar YAML", true, "eng"), + ) + }) + + // songLyrics extension v2: getLyricsBySongId?enhanced=true opts in to + // word/syllable-level timing (cueLine) and the kind classification. Every + // format gains kind="main" for a single untyped lyric layer; only formats + // that carry word-level timing (ELRC, TTML word spans, Lyricsfile YAML) + // surface a cueLine. Line-level formats (LRC, SRT, plain) still yield none. + Describe("getLyricsBySongId v2 (enhanced)", func() { + DescribeTable("returns enhanced lyrics, with cueLine only for word-level sources", + func(title string, wantCueLine bool) { + resp := doReq("getLyricsBySongId", "id", songID(title), "enhanced", "true") + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Kind).To(Equal("main")) + if wantCueLine { + Expect(got.CueLine).ToNot(BeEmpty()) + // The first line has one cue per word: "Should auld acquaintance be forgot,". + Expect(got.CueLine[0].Cue).To(HaveLen(5)) + Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should ")) + } else { + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Line).ToNot(BeEmpty()) + } + }, + Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true), + Entry("embedded TTML (word-level spans)", "Embedded TTML", true), + Entry("YAML sidecar (word-level)", "Sidecar YAML", true), + Entry("embedded plain text (no timing)", "Embedded Plain", false), + Entry("LRC sidecar (line-level)", "Sidecar LRC", false), + Entry("SRT sidecar (line-level)", "Sidecar SRT", false), + ) + }) + + // getLyrics is the original Subsonic (pre-OpenSubsonic) endpoint. It looks up + // by artist/title and returns the main lyric flattened to plain text — every + // line's Value joined by newlines, with all timing/markup dropped. Synced and + // word-level formats (ELRC/TTML/SRT/YAML) all degrade to plain text here. + Describe("getLyrics (legacy artist/title)", func() { + DescribeTable("returns the main lyric as plain text across formats and sources", + func(title string) { + resp := doReq("getLyrics", "artist", "Lyric Tester", "title", title) + Expect(resp.Status).To(Equal("ok")) + Expect(resp.Lyrics).ToNot(BeNil()) + Expect(resp.Lyrics.Artist).To(Equal("Lyric Tester")) + Expect(resp.Lyrics.Title).To(Equal(title)) + Expect(resp.Lyrics.Value).To(ContainSubstring(firstFixtureLine)) + + // No timing markup leaks into the plain-text value, regardless of the + // source format: no LRC brackets/word markers, SRT arrows, or XML tags. + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("[")) + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("-->")) + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("<")) + }, + Entry("embedded enhanced LRC", "Embedded Enhanced LRC"), + Entry("embedded plain text", "Embedded Plain"), + Entry("embedded TTML", "Embedded TTML"), + Entry("LRC sidecar", "Sidecar LRC"), + Entry("SRT sidecar", "Sidecar SRT"), + Entry("YAML sidecar", "Sidecar YAML"), + ) + }) +}) diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index a837da124..e652cf492 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -142,7 +142,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) for _, a := range resp.AlbumList.Album { Expect(a.Title).ToNot(Equal("Symphony No. 9")) } diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index e348bc6b9..00b60ad6f 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -115,9 +115,9 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(6)) - Expect(resp.SearchResult3.Album).To(HaveLen(7)) - Expect(resp.SearchResult3.Song).To(HaveLen(14)) + Expect(resp.SearchResult3.Artist).To(HaveLen(7)) + Expect(resp.SearchResult3.Album).To(HaveLen(8)) + Expect(resp.SearchResult3.Song).To(HaveLen(20)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go index 40161470b..1b8d34eb1 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -47,7 +47,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), - lyrics.NewLyrics(nil), + lyrics.NewLyrics(ds, nil), decider, sonicSvc, ) diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index c7b8a4d4f..8fc407e9e 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -25,6 +25,7 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + var shareOwner *model.User if info.shareID != "" { share, err := pub.ds.Share(ctx).Get(info.shareID) if err != nil { @@ -35,6 +36,12 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { checkShareError(ctx, w, model.ErrExpired, info.shareID) return } + shareOwner, err = pub.ds.User(ctx).Get(share.UserID) + if err != nil { + log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } } mf, err := pub.ds.MediaFile(ctx).Get(info.id) @@ -48,6 +55,12 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + // 404 rather than 403 so the response doesn't reveal whether the id exists. + if shareOwner != nil && !shareOwner.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "not found", http.StatusNotFound) + return + } + stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{ Format: info.format, BitRate: info.bitrate, }) diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index f43d75a26..6fa083045 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -131,11 +131,22 @@ var _ = Describe("handleStream", func() { return w } - It("passes all validation and reaches the streamer for a valid token", func() { + shareOwnedBy := func(owner model.User, mf model.MediaFile) { shareRepo.ID = "share123" + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID} + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&owner)).To(Succeed()) + ds.MockedUser = userRepo mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + mfRepo.SetData(model.MediaFiles{mf}) ds.MockedMediaFile = mfRepo + } + + It("passes all validation and reaches the streamer for a valid token", func() { + shareOwnedBy( + model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, + model.MediaFile{ID: "mf-123", Title: "Test Song"}, + ) claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) @@ -146,6 +157,33 @@ var _ = Describe("handleStream", func() { Expect(streamer.req.BitRate).To(Equal(192)) }) + It("returns 404 when the track is outside the share owner's libraries", func() { + shareOwnedBy( + model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, + model.MediaFile{ID: "mf-restricted", Title: "Other Lib Track", LibraryID: 2}, + ) + + claims := auth.Claims{ID: "mf-restricted", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(streamer.called).To(BeFalse()) + }) + + It("streams a track inside the share owner's libraries", func() { + shareOwnedBy( + model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, + model.MediaFile{ID: "mf-ok", Title: "OK", LibraryID: 1}, + ) + + claims := auth.Claims{ID: "mf-ok", Format: "mp3", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.called).To(BeTrue()) + }) + It("returns 400 for an expired token", func() { claims := auth.Claims{ID: "mf-123", ShareID: "share123"} token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) diff --git a/server/subsonic/api_suite_test.go b/server/subsonic/api_suite_test.go index a83f2f0eb..485daca58 100644 --- a/server/subsonic/api_suite_test.go +++ b/server/subsonic/api_suite_test.go @@ -1,9 +1,13 @@ package subsonic import ( + "io/fs" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +19,16 @@ func TestSubsonicApi(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Subsonic API Suite") } + +// newLocalStorage fatals if the default extractor is not registered. +// Register a no-op so storage.For works in sidecar-lyrics tests. +var _ = BeforeSuite(func() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return &subsonicNoopExtractor{} + }) +}) + +type subsonicNoopExtractor struct{} + +func (e *subsonicNoopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } +func (e *subsonicNoopExtractor) Version() string { return "noop" } diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index c3710394f..d19e163dd 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -106,21 +106,6 @@ func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { return addDefaultFilters(options) } -func SongsByArtistTitleWithLyricsFirst(artist, title string) Options { - return addDefaultFilters(Options{ - Sort: "lyrics, updated_at", - Order: "desc", - Max: 1, - Filters: And{ - Eq{"title": title}, - Or{ - persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), - persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), - }, - }, - }) -} - func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options { if len(musicFolderIds) == 0 { return opts diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e6c6f9114..b986200ae 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -217,7 +217,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = new(mf.BirthTime) + child.Created = new(mediaFileCreatedAt(mf)) child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" @@ -290,6 +290,12 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op } child.Contributors = contributors child.ExplicitStatus = mapExplicitStatus(mf.ExplicitStatus) + child.Works = slice.Map(mf.Works(), func(w model.Work) responses.Work { + return responses.Work{Name: w.Name, MusicBrainzId: w.MbzWorkID} + }) + child.Movements = slice.Map(mf.Movements(), func(m model.Movement) responses.Movement { + return responses.Movement{Name: m.Name, Number: m.Number, Count: m.Count} + }) return &child } @@ -320,18 +326,36 @@ func sanitizeSlashes(target string) string { return strings.ReplaceAll(target, "/", "_") } -// albumCreatedAt returns a best-effort timestamp for the album's `created` -// field, which is required by the OpenSubsonic spec but may be zero on legacy -// DB rows. Falls back to UpdatedAt → ImportedAt; can still return zero if all -// three are unset. +// albumCreatedAt mirrors the column used by recentlyAddedSort so clients can +// reproduce the "recently added" order locally: UpdatedAt when +// RecentlyAddedByModTime is set, CreatedAt otherwise. The other timestamps are +// fallbacks for legacy rows; returns zero only when all three are unset. func albumCreatedAt(al model.Album) time.Time { - if !al.CreatedAt.IsZero() { - return al.CreatedAt + candidates := []time.Time{al.CreatedAt, al.UpdatedAt, al.ImportedAt} + if conf.Server.RecentlyAddedByModTime { + candidates = []time.Time{al.UpdatedAt, al.CreatedAt, al.ImportedAt} } - if !al.UpdatedAt.IsZero() { - return al.UpdatedAt + for _, t := range candidates { + if !t.IsZero() { + return t + } } - return al.ImportedAt + return time.Time{} +} + +// mediaFileCreatedAt is the song counterpart of albumCreatedAt, tracking +// mediaFileRecentlyAddedSort; BirthTime is the legacy fallback. +func mediaFileCreatedAt(mf model.MediaFile) time.Time { + candidates := []time.Time{mf.CreatedAt, mf.UpdatedAt, mf.BirthTime} + if conf.Server.RecentlyAddedByModTime { + candidates = []time.Time{mf.UpdatedAt, mf.CreatedAt, mf.BirthTime} + } + for _, t := range candidates { + if !t.IsZero() { + return t + } + } + return time.Time{} } func childFromAlbum(ctx context.Context, al model.Album) responses.Child { @@ -495,48 +519,6 @@ func mapExplicitStatus(explicitStatus string) string { return "" } -func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric { - lines := make([]responses.Line, len(lyrics.Line)) - - for i, line := range lyrics.Line { - lines[i] = responses.Line{ - Start: line.Start, - Value: line.Value, - } - } - - structured := responses.StructuredLyric{ - DisplayArtist: lyrics.DisplayArtist, - DisplayTitle: lyrics.DisplayTitle, - Lang: lyrics.Lang, - Line: lines, - Offset: lyrics.Offset, - Synced: lyrics.Synced, - } - - if structured.DisplayArtist == "" { - structured.DisplayArtist = mf.Artist - } - if structured.DisplayTitle == "" { - structured.DisplayTitle = mf.Title - } - - return structured -} - -func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList { - lyricList := make(responses.StructuredLyrics, len(lyricsList)) - - for i, lyrics := range lyricsList { - lyricList[i] = buildStructuredLyric(mf, lyrics) - } - - res := &responses.LyricsList{ - StructuredLyrics: lyricList, - } - return res -} - // getUserAccessibleLibraries returns the list of libraries the current user has access to. func getUserAccessibleLibraries(ctx context.Context) []model.Library { user := getUser(ctx) diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 2ae6eb28e..3741462a2 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "encoding/json" "net/http/httptest" "time" @@ -358,6 +359,52 @@ var _ = Describe("helpers", func() { Expect(osChild).ToNot(BeNil()) Expect(osChild.Comment).To(Equal("Test Comment")) }) + + It("populates works and movements from tags", func() { + mf.Tags = model.Tags{ + model.TagWork: {"Symphony No. 5"}, + model.TagMusicBrainzWorkID: {"abc-123"}, + model.TagMovementName: {"I. Allegro"}, + model.TagMovementNumber: {"1"}, + model.TagMovementTotal: {"4"}, + } + osChild := osChildFromMediaFile(ctx, mf) + Expect(osChild).ToNot(BeNil()) + Expect(osChild.Works).To(Equal(responses.Array[responses.Work]{ + {Name: "Symphony No. 5", MusicBrainzId: "abc-123"}, + })) + Expect(osChild.Movements).To(Equal(responses.Array[responses.Movement]{ + {Name: "I. Allegro", Number: 1, Count: 4}, + })) + }) + + It("returns empty works and movements when no classical tags are present", func() { + osChild := osChildFromMediaFile(ctx, mf) + Expect(osChild).ToNot(BeNil()) + Expect(osChild.Works).To(BeEmpty()) + Expect(osChild.Movements).To(BeEmpty()) + }) + + It("serializes works and movements to spec-compliant JSON", func() { + mf.Tags = model.Tags{ + model.TagWork: {"Symphony No. 5"}, + model.TagMovementName: {"I. Allegro"}, + model.TagMovementNumber: {"1"}, + } + osChild := osChildFromMediaFile(ctx, mf) + data, err := json.Marshal(osChild) + Expect(err).ToNot(HaveOccurred()) + + var got map[string]any + Expect(json.Unmarshal(data, &got)).To(Succeed()) + // Required name present; optional musicBrainzId/count omitted (omitempty); number present. + Expect(got).To(HaveKeyWithValue("works", []any{ + map[string]any{"name": "Symphony No. 5"}, + })) + Expect(got).To(HaveKeyWithValue("movements", []any{ + map[string]any{"name": "I. Allegro", "number": float64(1)}, + })) + }) }) Context("when legacy clients list is empty", func() { @@ -572,31 +619,122 @@ var _ = Describe("helpers", func() { }) Describe("buildAlbumID3 Created field", func() { - It("uses CreatedAt when set", func() { - t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) - al := model.Album{ID: "a1", Name: "A", CreatedAt: t} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).To(Equal(t)) + When("RecentlyAddedByModTime is false", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = false + }) + + It("uses CreatedAt when set", func() { + t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + al := model.Album{ID: "a1", Name: "A", CreatedAt: t} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(t)) + }) + + It("falls back to UpdatedAt when CreatedAt is zero", func() { + updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) + al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(updated)) + }) + + It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { + imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) + al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(imported)) + }) + + It("leaves Created as zero time when all timestamps are zero", func() { + al := model.Album{ID: "a4", Name: "A"} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created.IsZero()).To(BeTrue()) + }) }) - It("falls back to UpdatedAt when CreatedAt is zero", func() { - updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) - al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).To(Equal(updated)) + When("RecentlyAddedByModTime is true", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = true + }) + + It("uses UpdatedAt even when CreatedAt is also set", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC) + al := model.Album{ID: "a5", Name: "A", CreatedAt: created, UpdatedAt: updated} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(updated)) + }) + + It("falls back to CreatedAt when UpdatedAt is zero", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + al := model.Album{ID: "a6", Name: "A", CreatedAt: created} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(created)) + }) + + It("falls back to ImportedAt when UpdatedAt and CreatedAt are zero", func() { + imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) + al := model.Album{ID: "a7", Name: "A", ImportedAt: imported} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(imported)) + }) + + It("leaves Created as zero time when all timestamps are zero", func() { + al := model.Album{ID: "a8", Name: "A"} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created.IsZero()).To(BeTrue()) + }) + }) + }) + + Describe("childFromMediaFile Created field", func() { + birth := time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC) + + When("RecentlyAddedByModTime is false", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = false + }) + + It("uses CreatedAt, not BirthTime", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + mf := model.MediaFile{ID: "s1", BirthTime: birth, CreatedAt: created} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(created)) + }) + + It("falls back to UpdatedAt when CreatedAt is zero", func() { + updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) + mf := model.MediaFile{ID: "s2", BirthTime: birth, UpdatedAt: updated} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(updated)) + }) + + It("falls back to BirthTime when CreatedAt and UpdatedAt are zero", func() { + mf := model.MediaFile{ID: "s3", BirthTime: birth} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(birth)) + }) }) - It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { - imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) - al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).To(Equal(imported)) - }) + When("RecentlyAddedByModTime is true", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = true + }) - It("leaves Created as zero time when all timestamps are zero", func() { - al := model.Album{ID: "a4", Name: "A"} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created.IsZero()).To(BeTrue()) + It("uses UpdatedAt even when CreatedAt is also set", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC) + mf := model.MediaFile{ID: "s4", BirthTime: birth, CreatedAt: created, UpdatedAt: updated} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(updated)) + }) + + It("falls back to CreatedAt when UpdatedAt is zero", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + mf := model.MediaFile{ID: "s5", BirthTime: birth, CreatedAt: created} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(created)) + }) }) }) diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go new file mode 100644 index 000000000..bfe1f1899 --- /dev/null +++ b/server/subsonic/lyrics.go @@ -0,0 +1,265 @@ +package subsonic + +import ( + "slices" + "sort" + "strings" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" +) + +// agentRoleMain is the OpenSubsonic agent role that marks the primary vocal +// layer; its cue line is emitted before other agents sharing the same index. +const agentRoleMain = "main" + +func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList { + filtered := lyricsList + if !enhanced { + // Without enhanced, only return main-kind entries (a blank kind is main). + filtered = nil + for _, l := range lyricsList { + if l.IsMainKind() { + filtered = append(filtered, l) + } + } + } + + lyricList := make(responses.StructuredLyrics, len(filtered)) + for i, lyrics := range filtered { + lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced) + } + return &responses.LyricsList{StructuredLyrics: lyricList} +} + +func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric { + agents := newLyricAgents(lyrics.Agents) + + lines := make([]responses.Line, len(lyrics.Line)) + var cueLines []responses.CueLine + for i, line := range lyrics.Line { + lines[i] = responses.Line{Start: line.Start, Value: line.Value} + if enhanced && len(line.Cue) > 0 { + cueLines = append(cueLines, buildCueLines(line, int32(i), agents)...) + } + } + + structured := responses.StructuredLyric{ + DisplayArtist: lyrics.DisplayArtist, + DisplayTitle: lyrics.DisplayTitle, + Lang: lyrics.Lang, + Line: lines, + CueLine: cueLines, + Offset: lyrics.Offset, + Synced: lyrics.Synced, + } + + if enhanced { + structured.Kind = lyrics.EffectiveKind() + if len(cueLines) > 0 && len(agents.response) > 0 { + structured.Agents = agents.response + } + } + + if structured.DisplayArtist == "" { + structured.DisplayArtist = mf.Artist + } + if structured.DisplayTitle == "" { + structured.DisplayTitle = mf.Title + } + return structured +} + +// lyricAgents indexes a lyric's agents by ID so cue lines can be ordered and +// the response agent list reused without rescanning the slice per line. +type lyricAgents struct { + orderByID map[string]int + roleByID map[string]string + response []responses.Agent +} + +func newLyricAgents(agents []model.Agent) lyricAgents { + a := lyricAgents{ + orderByID: make(map[string]int, len(agents)), + roleByID: make(map[string]string, len(agents)), + response: make([]responses.Agent, 0, len(agents)), + } + for i, agent := range agents { + a.orderByID[agent.ID] = i + a.roleByID[agent.ID] = agent.Role + a.response = append(a.response, responses.Agent{ID: agent.ID, Role: agent.Role, Name: agent.Name}) + } + return a +} + +// buildCueLines splits a line's cues by agent and emits one CueLine per agent, +// ordered main-role first then by the agent's declared order. +func buildCueLines(line model.Line, index int32, agents lyricAgents) []responses.CueLine { + agentOrder := make([]string, 0, 2) + cuesByAgent := make(map[string][]model.Cue) + for _, cue := range line.Cue { + if cue.Start == nil { + continue + } + agentID := strings.TrimSpace(cue.AgentID) + if _, exists := cuesByAgent[agentID]; !exists { + agentOrder = append(agentOrder, agentID) + } + cuesByAgent[agentID] = append(cuesByAgent[agentID], cue) + } + + sort.SliceStable(agentOrder, func(i, j int) bool { + return agents.less(agentOrder[i], agentOrder[j], i, j) + }) + + cueLines := make([]responses.CueLine, 0, len(agentOrder)) + for _, agentID := range agentOrder { + value := line.Value + cues := cuesByAgent[agentID] + if len(agentOrder) > 1 { + value, cues = buildAgentCueLineValue(line.Value, cues, line.Cue, agentID) + } + cueLine := responses.CueLine{ + Index: index, + Start: line.Start, + End: line.End, + Value: value, + Cue: buildLyricCues(cues, line.End), + } + if agentID != "" { + cueLine.AgentID = agentID + } + cueLines = append(cueLines, cueLine) + } + return cueLines +} + +// less orders two agent IDs: the main role wins, then the declared agent order, +// then known-before-unknown, then the original encounter order (origI/origJ). +func (a lyricAgents) less(left, right string, origI, origJ int) bool { + leftMain := a.roleByID[left] == agentRoleMain + rightMain := a.roleByID[right] == agentRoleMain + if leftMain != rightMain { + return leftMain + } + + leftOrder, leftOK := a.orderByID[left] + rightOrder, rightOK := a.orderByID[right] + if leftOK && rightOK && leftOrder != rightOrder { + return leftOrder < rightOrder + } + if leftOK != rightOK { + return leftOK + } + return origI < origJ +} + +func buildAgentCueLineValue(lineValue string, cues, allCues []model.Cue, agentID string) (string, []model.Cue) { + if len(cues) == 0 { + return "", nil + } + + remapped := slices.Clone(cues) + var value strings.Builder + leadingGap := cueLineGap(lineValue, 0, remapped[0].ByteStart, allCues, agentID) + if strings.TrimSpace(leadingGap) != "" { + value.WriteString(leadingGap) + } + + previousEnd := -1 + for i := range remapped { + originalStart := remapped[i].ByteStart + originalEnd := remapped[i].ByteEnd + if i > 0 { + value.WriteString(cueLineGap(lineValue, previousEnd+1, originalStart, allCues, agentID)) + } + + remapped[i].ByteStart = value.Len() + value.WriteString(remapped[i].Value) + remapped[i].ByteEnd = value.Len() - 1 + previousEnd = originalEnd + } + + trailingGap := cueLineGap(lineValue, previousEnd+1, len(lineValue), allCues, agentID) + if strings.TrimSpace(trailingGap) != "" { + value.WriteString(trailingGap) + } + return value.String(), remapped +} + +type byteRange struct { + start int + end int +} + +func cueLineGap(source string, start, end int, allCues []model.Cue, agentID string) string { + start = max(start, 0) + end = min(end, len(source)) + if start >= end { + return "" + } + + excluded := make([]byteRange, 0, 1) + for _, cue := range allCues { + if strings.TrimSpace(cue.AgentID) == agentID { + continue + } + cueStart := max(cue.ByteStart, start) + cueEnd := min(cue.ByteEnd+1, end) + if cueStart < cueEnd { + excluded = append(excluded, byteRange{start: cueStart, end: cueEnd}) + } + } + + if len(excluded) == 0 { + return source[start:end] + } + + sort.SliceStable(excluded, func(i, j int) bool { + return excluded[i].start < excluded[j].start + }) + + var gap strings.Builder + cursor := start + for _, r := range excluded { + if r.start > cursor { + gap.WriteString(source[cursor:r.start]) + } + cursor = max(cursor, r.end) + } + if cursor < end { + gap.WriteString(source[cursor:end]) + } + return gap.String() +} + +func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue { + if len(cues) == 0 { + return nil + } + + // Only resolve end times when at least one cue carries one; otherwise the + // group is start-only and must stay that way. + hasAnyEnd := slices.ContainsFunc(cues, func(c model.Cue) bool { return c.End != nil }) + if hasAnyEnd { + cues = model.NormalizeCueEnds(cues, lineEnd) + } + + out := make([]responses.LyricCue, 0, len(cues)) + for i := range cues { + if cues[i].Start == nil { + continue + } + cue := responses.LyricCue{ + Start: *cues[i].Start, + Value: cues[i].Value, + ByteStart: cues[i].ByteStart, + ByteEnd: cues[i].ByteEnd, + } + if hasAnyEnd { + cue.End = cues[i].End + } + out = append(out, cue) + } + return out +} diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go new file mode 100644 index 000000000..3d9881872 --- /dev/null +++ b/server/subsonic/lyrics_test.go @@ -0,0 +1,842 @@ +package subsonic + +import ( + "encoding/json" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetLyricsBySongId", func() { + var router *Router + var ds model.DataStore + mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}} + + BeforeEach(func() { + ds = &tests.MockDataStore{ + MockedMediaFile: mockRepo, + } + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + DeferCleanup(configtest.SetupConfig()) + conf.Server.LyricsPriority = "embedded,.lrc" + }) + + const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" + const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" + const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" + var times = []int64{18800, 22801} + + compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { + Expect(actual).ToNot(BeNil()) + Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) + for i, realLyric := range actual.StructuredLyrics { + expectedLyric := expected.StructuredLyrics[i] + + Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) + Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) + Expect(realLyric.Kind).To(Equal(expectedLyric.Kind)) + Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) + Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) + Expect(realLyric.Agents).To(Equal(expectedLyric.Agents)) + + if expectedLyric.Offset == nil { + Expect(realLyric.Offset).To(BeNil()) + } else { + Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) + } + + Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) + for j, realLine := range realLyric.Line { + expectedLine := expectedLyric.Line[j] + Expect(realLine.Value).To(Equal(expectedLine.Value)) + + if expectedLine.Start == nil { + Expect(realLine.Start).To(BeNil()) + } else { + Expect(*realLine.Start).To(Equal(*expectedLine.Start)) + } + } + + Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine))) + for j, realCueLine := range realLyric.CueLine { + expectedCueLine := expectedLyric.CueLine[j] + Expect(realCueLine.Index).To(Equal(expectedCueLine.Index)) + Expect(realCueLine.Value).To(Equal(expectedCueLine.Value)) + Expect(realCueLine.AgentID).To(Equal(expectedCueLine.AgentID)) + if expectedCueLine.Start == nil { + Expect(realCueLine.Start).To(BeNil()) + } else { + Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start)) + } + if expectedCueLine.End == nil { + Expect(realCueLine.End).To(BeNil()) + } else { + Expect(*realCueLine.End).To(Equal(*expectedCueLine.End)) + } + + Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue))) + for k, realCue := range realCueLine.Cue { + expectedCue := expectedCueLine.Cue[k] + Expect(realCue.Value).To(Equal(expectedCue.Value)) + Expect(realCue.Start).To(Equal(expectedCue.Start)) + Expect(realCue.ByteStart).To(Equal(expectedCue.ByteStart)) + Expect(realCue.ByteEnd).To(Equal(expectedCue.ByteEnd)) + if expectedCue.End == nil { + Expect(realCue.End).To(BeNil()) + } else { + Expect(*realCue.End).To(Equal(*expectedCue.End)) + } + } + } + } + } + + It("should return mixed lyrics", func() { + r := newGetRequest("id=1") + syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics)) + unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics)) + synced, _ := syncedList.Main() + unsynced, _ := unsyncedList.Main() + lyricsJson, err := json.Marshal(model.LyricList{ + synced, unsynced, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + Lang: "eng", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + }, + { + Lang: "xxx", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: false, + Line: []responses.Line{ + { + Value: "We're no strangers to love", + }, + { + Value: "You know the rules and so do I", + }, + }, + }, + }, + }) + }) + + It("should parse lrc metadata", func() { + r := newGetRequest("id=1") + syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(metadata+"\n"+syncedLyrics)) + synced, _ := syncedList.Main() + lyricsJson, err := json.Marshal(model.LyricList{ + synced, + }) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "That one song", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + Offset: new(int64(-100)), + }, + }, + }) + }) + + It("should return multilingual TTML sidecar lyrics", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1") + + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + LibraryPath: fixturesDir, + Path: "test.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + porTime := int64(18800) + ttmlTime := int64(22800) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: &ttmlTime, + Value: "You know the rules and so do I", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "por", + Synced: true, + Line: []responses.Line{ + { + Start: &porTime, + Value: "Nao somos estranhos ao amor", + }, + }, + }, + }, + }) + }) + + It("should return metadata-linked translation and pronunciation tracks from TTML", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1&enhanced=true") + + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + LibraryPath: fixturesDir, + Path: "test-metadata.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + mainStartA := int64(1000) + mainStartB := int64(2000) + tokenStartA := int64(2000) + tokenEndA := int64(2300) + tokenStartB := int64(2300) + tokenEndB := int64(2600) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "ja", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "こんにちは", + }, + { + Start: &mainStartB, + Value: "こんばんは", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "translation", + Lang: "es", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "Hola", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "pronunciation", + Lang: "ja-latn", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartB, + Value: "konni", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &mainStartB, + End: &tokenEndB, + Value: "konni", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 1, + Value: "ko", + }, + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 2, + ByteEnd: 4, + Value: "nni", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should return cue lines for songLyrics v2 clients with enhanced=true", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + tokenStartA := int64(1000) + tokenEndA := int64(1400) + tokenStartB := int64(2000) + tokenEndB := int64(2500) + lyricsJson, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Agents: []model.Agent{{ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}}, + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + Cue: []model.Cue{ + { + Start: &tokenStartA, + End: &tokenEndA, + Value: "Hello", + ByteStart: 0, + ByteEnd: 4, + AgentID: "lead", + }, + { + Start: &tokenStartB, + End: &tokenEndB, + Value: "echo", + ByteStart: 6, + ByteEnd: 9, + AgentID: "__nd_bg__|lead", + }, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Agents: []responses.Agent{ + {ID: "lead", Role: "main"}, + {ID: "__nd_bg__|lead", Role: "bg"}, + }, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Hello echo", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "Hello", + AgentID: "lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 4, + Value: "Hello", + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "echo", + AgentID: "__nd_bg__|lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 0, + ByteEnd: 3, + Value: "echo", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should preserve shared edge text when remapping agent cue lines", func() { + lineStart := int64(1000) + lineEnd := int64(2000) + cueStart := int64(1200) + cueEnd := int64(1800) + + cueLines := buildCueLines(model.Line{ + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + Cue: []model.Cue{ + { + Start: &cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + AgentID: "lead", + }, + { + Start: &cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + AgentID: "__nd_bg__|lead", + }, + }, + }, 0, newLyricAgents([]model.Agent{ + {ID: "lead", Role: "main"}, + {ID: "__nd_bg__|lead", Role: "bg"}, + })) + + Expect(cueLines).To(Equal([]responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + AgentID: "lead", + Cue: []responses.LyricCue{ + { + Start: cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + AgentID: "__nd_bg__|lead", + Cue: []responses.LyricCue{ + { + Start: cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + }, + }, + }, + })) + }) + + It("should remap cue offsets for interleaved agent cue lines", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(82889) + lineEnd := int64(86859) + realStart := int64(85593) + realEnd := int64(85934) + slowStart := int64(85934) + slowEnd := int64(86751) + bgStartA := int64(83881) + bgEndA := int64(84243) + bgStartB := int64(86232) + bgEndB := int64(86859) + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Agents: []model.Agent{{ID: "v2", Role: "main"}, {ID: "__nd_bg__|v2", Role: "bg"}}, + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "real slow (When you slide)", + Cue: []model.Cue{ + { + Start: &realStart, + End: &realEnd, + Value: "real", + ByteStart: 0, + ByteEnd: 3, + AgentID: "v2", + }, + { + Start: &slowStart, + End: &slowEnd, + Value: "slow", + ByteStart: 5, + ByteEnd: 8, + AgentID: "v2", + }, + { + Start: &bgStartA, + End: &bgEndA, + Value: "(When you", + ByteStart: 10, + ByteEnd: 18, + AgentID: "__nd_bg__|v2", + }, + { + Start: &bgStartB, + End: &bgEndB, + Value: "slide)", + ByteStart: 20, + ByteEnd: 25, + AgentID: "__nd_bg__|v2", + }, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Agents: []responses.Agent{ + {ID: "v2", Role: "main"}, + {ID: "__nd_bg__|v2", Role: "bg"}, + }, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "real slow (When you slide)", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "real slow", + AgentID: "v2", + Cue: []responses.LyricCue{ + { + Start: realStart, + End: &realEnd, + ByteStart: 0, + ByteEnd: 3, + Value: "real", + }, + { + Start: slowStart, + End: &slowEnd, + ByteStart: 5, + ByteEnd: 8, + Value: "slow", + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(When you slide)", + AgentID: "__nd_bg__|v2", + Cue: []responses.LyricCue{ + { + Start: bgStartA, + End: &bgEndA, + ByteStart: 0, + ByteEnd: 8, + Value: "(When you", + }, + { + Start: bgStartB, + End: &bgEndB, + ByteStart: 10, + ByteEnd: 15, + Value: "slide)", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should keep enhanced line-level lyrics when no cue data is available", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Kind: "main", + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Line without word timing", + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Line without word timing", + }, + }, + }, + }, + }) + }) + + It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() { + r := newGetRequest("id=1&enhanced=true") + + asciiLineStart := int64(0) + asciiLineEnd := int64(2400) + asciiCueStartA := int64(0) + asciiCueEndA := int64(300) + asciiCueStartB := int64(900) + asciiCueEndB := int64(1300) + asciiCueStartC := int64(1300) + asciiCueEndC := int64(1600) + asciiCueStartD := int64(1600) + + utfLineStart := int64(2747) + utfLineEnd := int64(6214) + utfCueStartA := int64(2747) + utfCueEndA := int64(3018) + utfCueStartB := int64(3018) + utfCueEndB := int64(3179) + utfCueStartC := int64(3582) + utfCueEndC := int64(4100) + utfCueStartD := int64(4500) + utfCueEndD := int64(6214) + + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []model.Cue{ + {Start: &asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: &asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: &asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: &asciiCueStartD, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []model.Cue{ + {Start: &utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: &utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: &utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: &utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + {Start: &asciiLineStart, Value: "Oh love love me tonight"}, + {Start: &utfLineStart, Value: "눈을 뜬 순간"}, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []responses.LyricCue{ + {Start: asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: asciiCueStartD, End: &asciiLineEnd, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Index: 1, + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []responses.LyricCue{ + {Start: utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }, + }) + }) +}) diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 9ab3a20b0..089a1fdda 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gravatar" "github.com/navidrome/navidrome/utils/req" @@ -98,22 +97,13 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { response := newResponse() lyricsResponse := responses.Lyrics{} response.Lyrics = &lyricsResponse - mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title)) - + structuredLyrics, err := api.lyrics.GetLyricsByArtistTitle(r.Context(), artist, title) if err != nil { return nil, err } - if len(mediaFiles) == 0 { - return response, nil - } - - structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0]) - if err != nil { - return nil, err - } - - if len(structuredLyrics) == 0 { + mainLyric, ok := structuredLyrics.Main() + if !ok { return response, nil } @@ -121,10 +111,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { lyricsResponse.Title = title var lyricsText strings.Builder - for _, line := range structuredLyrics[0].Line { + for _, line := range mainLyric.Line { lyricsText.WriteString(line.Value + "\n") } - lyricsResponse.Value = lyricsText.String() return response, nil @@ -146,8 +135,10 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro return nil, err } + enhanced, _ := req.Params(r).Bool("enhanced") + response := newResponse() - response.LyricsList = buildLyricsList(mediaFile, structuredLyrics) + response.LyricsList = buildLyricsList(mediaFile, structuredLyrics, enhanced) return response, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 12c0dff56..228427d5a 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -8,6 +8,7 @@ import ( "errors" "io" "net/http/httptest" + "path/filepath" "slices" "time" @@ -16,7 +17,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" @@ -113,34 +113,19 @@ var _ = Describe("MediaRetrievalController", func() { Describe("GetLyrics", func() { It("should return data for given artist & title", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I") + lyricsList, _ := model.ParseLyrics(".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")) + lyrics, _ := lyricsList.Main() lyricsJson, err := json.Marshal(model.LyricList{ - *lyrics, + lyrics, }) Expect(err).ToNot(HaveOccurred()) - baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) mockRepo.SetData(model.MediaFiles{ { - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer - }, - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older - }, - { - ID: "3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), }, }) response, err := router.GetLyrics(r) @@ -149,6 +134,26 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) + It("should surface the main-kind track when translation tracks are present", func() { + r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + start := int64(0) + lyricsJSON, err := json.Marshal(model.LyricList{ + {Kind: model.LyricKindTranslation, Lang: "por", Line: []model.Line{{Start: &start, Value: "Nunca vou te decepcionar"}}}, + {Kind: model.LyricKindMain, Lang: "eng", Line: []model.Line{{Start: &start, Value: "Never gonna let you down"}}}, + }) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + response, err := router.GetLyrics(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Lyrics.Value).To(Equal("Never gonna let you down\n")) + }) It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() { r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa") mockRepo.SetData(model.MediaFiles{}) @@ -160,18 +165,15 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { - Path: "tests/fixtures/test.mp3", - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - }, - { - Path: "tests/fixtures/test.mp3", - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", + LibraryPath: fixturesDir, + Path: "test.mp3", + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", }, }) response, err := router.GetLyrics(r) @@ -181,142 +183,6 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) }) - - Describe("GetLyricsBySongId", func() { - const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" - const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" - var times = []int64{18800, 22801} - - compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { - Expect(actual).ToNot(BeNil()) - Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) - for i, realLyric := range actual.StructuredLyrics { - expectedLyric := expected.StructuredLyrics[i] - - Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) - Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) - Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) - Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) - - if expectedLyric.Offset == nil { - Expect(realLyric.Offset).To(BeNil()) - } else { - Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) - } - - Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) - for j, realLine := range realLyric.Line { - expectedLine := expectedLyric.Line[j] - Expect(realLine.Value).To(Equal(expectedLine.Value)) - - if expectedLine.Start == nil { - Expect(realLine.Start).To(BeNil()) - } else { - Expect(*realLine.Start).To(Equal(*expectedLine.Start)) - } - } - } - } - - It("should return mixed lyrics", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", syncedLyrics) - unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, *unsynced, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - Lang: "eng", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - }, - { - Lang: "xxx", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: false, - Line: []responses.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - }, - }, - }) - }) - - It("should parse lrc metadata", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, - }) - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - Offset: new(int64(-100)), - }, - }, - }) - }) - }) }) type fakeArtwork struct { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 85edb1012..97b3cafcc 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -11,7 +11,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, - {Name: "songLyrics", Versions: []int32{1}}, + {Name: "songLyrics", Versions: []int32{1, 2}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 3ccbf232e..e4217303f 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -58,7 +58,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(6), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), @@ -88,7 +88,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(7), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), 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 9d9ae2195..d6b195f58 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 @@ -59,7 +59,9 @@ "explicitStatus": "explicit", "groupings": [ "Soundtrack" - ] + ], + "works": [], + "movements": [] } ] } 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 bff0bd20c..f776c7535 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 @@ -171,7 +171,9 @@ "groupings": [ "Soundtrack", "Live" - ] + ], + "works": [], + "movements": [] }, { "id": "2", @@ -217,7 +219,9 @@ "contributors": [], "displayComposer": "", "explicitStatus": "", - "groupings": [] + "groupings": [], + "works": [], + "movements": [] } ] } 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 4c0ea6c68..fef60c9b1 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 @@ -114,6 +114,22 @@ "groupings": [ "Soundtrack", "Live" + ], + "works": [ + { + "name": "Symphony No. 5", + "musicBrainzId": "mbz-work-1" + }, + { + "name": "Encore" + } + ], + "movements": [ + { + "name": "I. Allegro", + "number": 1, + "count": 4 + } ] }, { @@ -146,7 +162,9 @@ "contributors": [], "displayComposer": "", "explicitStatus": "", - "groupings": [] + "groupings": [], + "works": [], + "movements": [] } ], "id": "1", 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 ddceb67d4..b626fd6ea 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 @@ -26,6 +26,9 @@ Soundtrack Live + + + 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 9a9ab1ff6..ea23dc5d6 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 @@ -29,7 +29,9 @@ "contributors": [], "displayComposer": "", "explicitStatus": "", - "groupings": [] + "groupings": [], + "works": [], + "movements": [] } ], "id": "", diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index dcb458932..252eee4c6 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -190,6 +190,8 @@ type OpenSubsonicChild struct { DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"` ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"` Groupings Array[string] `xml:"groupings,omitempty" json:"groupings"` + Works Array[Work] `xml:"works,omitempty" json:"works"` + Movements Array[Movement] `xml:"movements,omitempty" json:"movements"` } type Songs struct { @@ -547,13 +549,39 @@ type Line struct { Value string `xml:",chardata" json:"value"` } +type LyricCue struct { + Start int64 `xml:"start,attr" json:"start"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + ByteStart int `xml:"byteStart,attr" json:"byteStart"` + ByteEnd int `xml:"byteEnd,attr" json:"byteEnd"` + Value string `xml:",chardata" json:"value"` +} + +type Agent struct { + ID string `xml:"id,attr" json:"id"` + Role string `xml:"role,attr" json:"role"` + Name string `xml:"name,attr,omitempty" json:"name,omitempty"` +} + +type CueLine struct { + Index int32 `xml:"index,attr" json:"index"` + Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + Value string `xml:"value,attr" json:"value"` + AgentID string `xml:"agentId,attr,omitempty" json:"agentId,omitempty"` + Cue []LyricCue `xml:"cue,omitempty" json:"cue,omitempty"` +} + type StructuredLyric struct { - DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` - Lang string `xml:"lang,attr" json:"lang"` - Line []Line `xml:"line" json:"line"` - Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` - Synced bool `xml:"synced,attr" json:"synced"` + DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` + Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` + Lang string `xml:"lang,attr" json:"lang"` + Line []Line `xml:"line" json:"line"` + Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` + CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` + Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` + Synced bool `xml:"synced,attr" json:"synced"` } type StructuredLyrics []StructuredLyric @@ -572,6 +600,17 @@ type ItemGenre struct { Name string `xml:"name,attr" json:"name"` } +type Work struct { + Name string `xml:"name,attr" json:"name"` + MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId,omitempty"` +} + +type Movement struct { + Name string `xml:"name,attr" json:"name"` + Number int32 `xml:"number,attr,omitempty" json:"number,omitempty"` + Count int32 `xml:"count,attr,omitempty" json:"count,omitempty"` +} + type ReplayGain struct { TrackGain *float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"` AlbumGain *float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 3166df875..586e46b63 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -239,6 +239,13 @@ var _ = Describe("Responses", func() { {Role: "composer", Artist: ArtistID3Ref{Id: "4", Name: "composer2"}}, }, ExplicitStatus: "clean", + Works: []Work{ + {Name: "Symphony No. 5", MusicBrainzId: "mbz-work-1"}, + {Name: "Encore"}, + }, + Movements: []Movement{ + {Name: "I. Allegro", Number: 1, Count: 4}, + }, } child[1].OpenSubsonicChild = &OpenSubsonicChild{ ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, diff --git a/tests/fixtures/bom-test.ttml b/tests/fixtures/bom-test.ttml new file mode 100644 index 000000000..319ab1f07 --- /dev/null +++ b/tests/fixtures/bom-test.ttml @@ -0,0 +1,2 @@ + +

BOM test line

diff --git a/tests/fixtures/bom-utf16-test.ttml b/tests/fixtures/bom-utf16-test.ttml new file mode 100644 index 000000000..a5621ef5d Binary files /dev/null and b/tests/fixtures/bom-utf16-test.ttml differ diff --git a/tests/fixtures/lyrics/auld-lang-syne.elrc b/tests/fixtures/lyrics/auld-lang-syne.elrc new file mode 100644 index 000000000..41342d910 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.elrc @@ -0,0 +1,27 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[lang:eng] +[00:00.00]<00:00.00>Should <00:00.90>auld <00:01.80>acquaintance <00:02.70>be <00:03.60>forgot, +[00:04.50]<00:04.50>And <00:05.40>never <00:06.30>brought <00:07.20>to <00:08.10>mind? +[00:09.00]<00:09.00>Should <00:09.90>auld <00:10.80>acquaintance <00:11.70>be <00:12.60>forgot, +[00:13.50]<00:13.50>And <00:14.62>auld <00:15.75>lang <00:16.88>syne? +[00:18.00]<00:18.00>For <00:18.75>auld <00:19.50>lang <00:20.25>syne, <00:21.00>my <00:21.75>dear, +[00:22.50]<00:22.50>For <00:23.62>auld <00:24.75>lang <00:25.88>syne, +[00:27.00]<00:27.00>We'll <00:27.64>tak <00:28.29>a <00:28.93>cup <00:29.57>o' <00:30.21>kindness <00:30.86>yet, +[00:31.50]<00:31.50>For <00:32.62>auld <00:33.75>lang <00:34.88>syne. +[00:36.00]<00:36.00>And <00:36.75>surely <00:37.50>ye'll <00:38.25>be <00:39.00>your <00:39.75>pint-stowp, +[00:40.50]<00:40.50>And <00:41.40>surely <00:42.30>I'll <00:43.20>be <00:44.10>mine, +[00:45.00]<00:45.00>And <00:45.56>we'll <00:46.12>tak <00:46.69>a <00:47.25>cup <00:47.81>o' <00:48.38>kindness <00:48.94>yet, +[00:49.50]<00:49.50>For <00:50.62>auld <00:51.75>lang <00:52.88>syne. +[00:54.00]<00:54.00>We <00:54.64>twa <00:55.29>hae <00:55.93>run <00:56.57>about <00:57.21>the <00:57.86>braes, +[00:58.50]<00:58.50>And <00:59.40>pou'd <01:00.30>the <01:01.20>gowans <01:02.10>fine, +[01:03.00]<01:03.00>But <01:03.64>we've <01:04.29>wander'd <01:04.93>mony <01:05.57>a <01:06.21>weary <01:06.86>fit, +[01:07.50]<01:07.50>Sin <01:08.62>auld <01:09.75>lang <01:10.88>syne. +[01:12.00]<01:12.00>We <01:12.64>twa <01:13.29>hae <01:13.93>paidl'd <01:14.57>in <01:15.21>the <01:15.86>burn, +[01:16.50]<01:16.50>Frae <01:17.40>morning <01:18.30>sun <01:19.20>till <01:20.10>dine, +[01:21.00]<01:21.00>But <01:21.64>seas <01:22.29>between <01:22.93>us <01:23.57>braid <01:24.21>hae <01:24.86>roar'd +[01:25.50]<01:25.50>Sin <01:26.62>auld <01:27.75>lang <01:28.88>syne. +[01:30.00]<01:30.00>And <01:30.64>there's <01:31.29>a <01:31.93>hand, <01:32.57>my <01:33.21>trusty <01:33.86>fiere, +[01:34.50]<01:34.50>And <01:35.25>gie's <01:36.00>a <01:36.75>hand <01:37.50>o' <01:38.25>thine, +[01:39.00]<01:39.00>And <01:39.64>we'll <01:40.29>tak <01:40.93>a <01:41.57>right <01:42.21>gude-willie <01:42.86>waught, +[01:43.50]<01:43.50>For <01:44.62>auld <01:45.75>lang <01:46.88>syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.lrc b/tests/fixtures/lyrics/auld-lang-syne.lrc new file mode 100644 index 000000000..56021870a --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.lrc @@ -0,0 +1,28 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[al:Traditional] +[lang:eng] +[00:00.00]Should auld acquaintance be forgot, +[00:04.50]And never brought to mind? +[00:09.00]Should auld acquaintance be forgot, +[00:13.50]And auld lang syne? +[00:18.00]For auld lang syne, my dear, +[00:22.50]For auld lang syne, +[00:27.00]We'll tak a cup o' kindness yet, +[00:31.50]For auld lang syne. +[00:36.00]And surely ye'll be your pint-stowp, +[00:40.50]And surely I'll be mine, +[00:45.00]And we'll tak a cup o' kindness yet, +[00:49.50]For auld lang syne. +[00:54.00]We twa hae run about the braes, +[00:58.50]And pou'd the gowans fine, +[01:03.00]But we've wander'd mony a weary fit, +[01:07.50]Sin auld lang syne. +[01:12.00]We twa hae paidl'd in the burn, +[01:16.50]Frae morning sun till dine, +[01:21.00]But seas between us braid hae roar'd +[01:25.50]Sin auld lang syne. +[01:30.00]And there's a hand, my trusty fiere, +[01:34.50]And gie's a hand o' thine, +[01:39.00]And we'll tak a right gude-willie waught, +[01:43.50]For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.srt b/tests/fixtures/lyrics/auld-lang-syne.srt new file mode 100644 index 000000000..116bec0bf --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.srt @@ -0,0 +1,95 @@ +1 +00:00:00,000 --> 00:00:04,500 +Should auld acquaintance be forgot, + +2 +00:00:04,500 --> 00:00:09,000 +And never brought to mind? + +3 +00:00:09,000 --> 00:00:13,500 +Should auld acquaintance be forgot, + +4 +00:00:13,500 --> 00:00:18,000 +And auld lang syne? + +5 +00:00:18,000 --> 00:00:22,500 +For auld lang syne, my dear, + +6 +00:00:22,500 --> 00:00:27,000 +For auld lang syne, + +7 +00:00:27,000 --> 00:00:31,500 +We'll tak a cup o' kindness yet, + +8 +00:00:31,500 --> 00:00:36,000 +For auld lang syne. + +9 +00:00:36,000 --> 00:00:40,500 +And surely ye'll be your pint-stowp, + +10 +00:00:40,500 --> 00:00:45,000 +And surely I'll be mine, + +11 +00:00:45,000 --> 00:00:49,500 +And we'll tak a cup o' kindness yet, + +12 +00:00:49,500 --> 00:00:54,000 +For auld lang syne. + +13 +00:00:54,000 --> 00:00:58,500 +We twa hae run about the braes, + +14 +00:00:58,500 --> 00:01:03,000 +And pou'd the gowans fine, + +15 +00:01:03,000 --> 00:01:07,500 +But we've wander'd mony a weary fit, + +16 +00:01:07,500 --> 00:01:12,000 +Sin auld lang syne. + +17 +00:01:12,000 --> 00:01:16,500 +We twa hae paidl'd in the burn, + +18 +00:01:16,500 --> 00:01:21,000 +Frae morning sun till dine, + +19 +00:01:21,000 --> 00:01:25,500 +But seas between us braid hae roar'd + +20 +00:01:25,500 --> 00:01:30,000 +Sin auld lang syne. + +21 +00:01:30,000 --> 00:01:34,500 +And there's a hand, my trusty fiere, + +22 +00:01:34,500 --> 00:01:39,000 +And gie's a hand o' thine, + +23 +00:01:39,000 --> 00:01:43,500 +And we'll tak a right gude-willie waught, + +24 +00:01:43,500 --> 00:01:48,000 +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.ttml b/tests/fixtures/lyrics/auld-lang-syne.ttml new file mode 100644 index 000000000..a08be29e2 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.ttml @@ -0,0 +1,31 @@ + + + +
+

Should auld acquaintance be forgot,

+

And never brought to mind?

+

Should auld acquaintance be forgot,

+

And auld lang syne?

+

For auld lang syne, my dear,

+

For auld lang syne,

+

We'll tak a cup o' kindness yet,

+

For auld lang syne.

+

And surely ye'll be your pint-stowp,

+

And surely I'll be mine,

+

And we'll tak a cup o' kindness yet,

+

For auld lang syne.

+

We twa hae run about the braes,

+

And pou'd the gowans fine,

+

But we've wander'd mony a weary fit,

+

Sin auld lang syne.

+

We twa hae paidl'd in the burn,

+

Frae morning sun till dine,

+

But seas between us braid hae roar'd

+

Sin auld lang syne.

+

And there's a hand, my trusty fiere,

+

And gie's a hand o' thine,

+

And we'll tak a right gude-willie waught,

+

For auld lang syne.

+
+ +
diff --git a/tests/fixtures/lyrics/auld-lang-syne.txt b/tests/fixtures/lyrics/auld-lang-syne.txt new file mode 100644 index 000000000..42ab8330e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.txt @@ -0,0 +1,24 @@ +Should auld acquaintance be forgot, +And never brought to mind? +Should auld acquaintance be forgot, +And auld lang syne? +For auld lang syne, my dear, +For auld lang syne, +We'll tak a cup o' kindness yet, +For auld lang syne. +And surely ye'll be your pint-stowp, +And surely I'll be mine, +And we'll tak a cup o' kindness yet, +For auld lang syne. +We twa hae run about the braes, +And pou'd the gowans fine, +But we've wander'd mony a weary fit, +Sin auld lang syne. +We twa hae paidl'd in the burn, +Frae morning sun till dine, +But seas between us braid hae roar'd +Sin auld lang syne. +And there's a hand, my trusty fiere, +And gie's a hand o' thine, +And we'll tak a right gude-willie waught, +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.yaml b/tests/fixtures/lyrics/auld-lang-syne.yaml new file mode 100644 index 000000000..ca2a3d32e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.yaml @@ -0,0 +1,95 @@ +version: '1.0' +metadata: + title: 'Auld Lang Syne' + artist: 'Robert Burns' + album: 'Traditional' + language: 'eng' +lines: + - text: "Should auld acquaintance be forgot," + start_ms: 0 + end_ms: 4500 + words: + - text: "Should " + start_ms: 0 + end_ms: 900 + - text: "auld " + start_ms: 900 + end_ms: 1800 + - text: "acquaintance " + start_ms: 1800 + end_ms: 2700 + - text: "be " + start_ms: 2700 + end_ms: 3600 + - text: "forgot," + start_ms: 3600 + end_ms: 4500 + - text: "And never brought to mind?" + start_ms: 4500 + end_ms: 9000 + - text: "Should auld acquaintance be forgot," + start_ms: 9000 + end_ms: 13500 + - text: "And auld lang syne?" + start_ms: 13500 + end_ms: 18000 + - text: "For auld lang syne, my dear," + start_ms: 18000 + end_ms: 22500 + - text: "For auld lang syne," + start_ms: 22500 + end_ms: 27000 + - text: "We'll tak a cup o' kindness yet," + start_ms: 27000 + end_ms: 31500 + - text: "For auld lang syne." + start_ms: 31500 + end_ms: 36000 + - text: "And surely ye'll be your pint-stowp," + start_ms: 36000 + end_ms: 40500 + - text: "And surely I'll be mine," + start_ms: 40500 + end_ms: 45000 + - text: "And we'll tak a cup o' kindness yet," + start_ms: 45000 + end_ms: 49500 + - text: "For auld lang syne." + start_ms: 49500 + end_ms: 54000 + - text: "We twa hae run about the braes," + start_ms: 54000 + end_ms: 58500 + - text: "And pou'd the gowans fine," + start_ms: 58500 + end_ms: 63000 + - text: "But we've wander'd mony a weary fit," + start_ms: 63000 + end_ms: 67500 + - text: "Sin auld lang syne." + start_ms: 67500 + end_ms: 72000 + - text: "We twa hae paidl'd in the burn," + start_ms: 72000 + end_ms: 76500 + - text: "Frae morning sun till dine," + start_ms: 76500 + end_ms: 81000 + - text: "But seas between us braid hae roar'd" + start_ms: 81000 + end_ms: 85500 + - text: "Sin auld lang syne." + start_ms: 85500 + end_ms: 90000 + - text: "And there's a hand, my trusty fiere," + start_ms: 90000 + end_ms: 94500 + - text: "And gie's a hand o' thine," + start_ms: 94500 + end_ms: 99000 + - text: "And we'll tak a right gude-willie waught," + start_ms: 99000 + end_ms: 103500 + - text: "For auld lang syne." + start_ms: 103500 + end_ms: 108000 diff --git a/tests/fixtures/symlink_chain/evil1.mp3 b/tests/fixtures/symlink_chain/evil1.mp3 new file mode 120000 index 000000000..79c5d6f02 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil1.mp3 @@ -0,0 +1 @@ +../index.html \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil2.mp3 b/tests/fixtures/symlink_chain/evil2.mp3 new file mode 120000 index 000000000..56d18ad24 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil2.mp3 @@ -0,0 +1 @@ +evil1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil3.mp3 b/tests/fixtures/symlink_chain/evil3.mp3 new file mode 120000 index 000000000..e1cac02e9 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil3.mp3 @@ -0,0 +1 @@ +evil2.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level1.mp3 b/tests/fixtures/symlink_chain/level1.mp3 new file mode 120000 index 000000000..887033521 --- /dev/null +++ b/tests/fixtures/symlink_chain/level1.mp3 @@ -0,0 +1 @@ +../test.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level2.mp3 b/tests/fixtures/symlink_chain/level2.mp3 new file mode 120000 index 000000000..eca2115ee --- /dev/null +++ b/tests/fixtures/symlink_chain/level2.mp3 @@ -0,0 +1 @@ +level1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level3.mp3 b/tests/fixtures/symlink_chain/level3.mp3 new file mode 120000 index 000000000..dd72f3cca --- /dev/null +++ b/tests/fixtures/symlink_chain/level3.mp3 @@ -0,0 +1 @@ +level2.mp3 \ No newline at end of file diff --git a/tests/fixtures/test-enhanced.lrc b/tests/fixtures/test-enhanced.lrc new file mode 100644 index 000000000..8f7b60f8c --- /dev/null +++ b/tests/fixtures/test-enhanced.lrc @@ -0,0 +1,6 @@ +[ar:Test Artist] +[ti:Enhanced Test] +[lang:eng] +[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here +[00:03.00]<00:03.00>More <00:03.50>words +[00:05.00]Plain line without inline markers diff --git a/tests/fixtures/test-instrumental.yaml b/tests/fixtures/test-instrumental.yaml new file mode 100644 index 000000000..84190a3b0 --- /dev/null +++ b/tests/fixtures/test-instrumental.yaml @@ -0,0 +1,6 @@ +version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true diff --git a/tests/fixtures/test-metadata.ttml b/tests/fixtures/test-metadata.ttml new file mode 100644 index 000000000..c0243c18f --- /dev/null +++ b/tests/fixtures/test-metadata.ttml @@ -0,0 +1,25 @@ + + + + + + + + Hola + + + + + konni + + + + + + +
+

こんにちは

+

こんばんは

+
+ +
diff --git a/tests/fixtures/test-overlapping.yaml b/tests/fixtures/test-overlapping.yaml new file mode 100644 index 000000000..c1f95a87b --- /dev/null +++ b/tests/fixtures/test-overlapping.yaml @@ -0,0 +1,24 @@ +version: '1.0' +metadata: + title: 'Duet' + artist: 'Lead and Echo' + language: 'eng' + +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 diff --git a/tests/fixtures/test-words.yaml b/tests/fixtures/test-words.yaml new file mode 100644 index 000000000..625098d6a --- /dev/null +++ b/tests/fixtures/test-words.yaml @@ -0,0 +1,17 @@ +version: '1.0' +metadata: + title: 'Karaoke Test' + artist: 'Test Artist' + language: 'eng' + +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 diff --git a/tests/fixtures/test.elrc b/tests/fixtures/test.elrc new file mode 100644 index 000000000..01c3d2cdd --- /dev/null +++ b/tests/fixtures/test.elrc @@ -0,0 +1,5 @@ +[ar:ELRC Artist] +[ti:ELRC Song] +[lang:eng] +[00:01.00]<00:01.00>Lead <00:01.50>words +[00:03.00]Fallback line diff --git a/tests/fixtures/test.srt b/tests/fixtures/test.srt new file mode 100644 index 000000000..3c9c09a39 --- /dev/null +++ b/tests/fixtures/test.srt @@ -0,0 +1,7 @@ +1 +00:00:18,800 --> 00:00:22,800 +We're from subtitles + +2 +00:00:22,801 --> 00:00:26,000 +Another subtitle line diff --git a/tests/fixtures/test.ttml b/tests/fixtures/test.ttml new file mode 100644 index 000000000..a85673a1b --- /dev/null +++ b/tests/fixtures/test.ttml @@ -0,0 +1,12 @@ + + + +
+

We're no strangers to love

+

You know the rules and so do I

+
+
+

Nao somos estranhos ao amor

+
+ +
diff --git a/tests/fixtures/test.yaml b/tests/fixtures/test.yaml new file mode 100644 index 000000000..bc5022b75 --- /dev/null +++ b/tests/fixtures/test.yaml @@ -0,0 +1,12 @@ +version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 + +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801 diff --git a/ui/embed.go b/ui/embed.go index 3e2c413b3..2d5fcd979 100644 --- a/ui/embed.go +++ b/ui/embed.go @@ -5,7 +5,7 @@ import ( "io/fs" ) -//go:embed build/* +//go:embed all:build var filesystem embed.FS func BuildAssets() fs.FS { diff --git a/ui/package-lock.json b/ui/package-lock.json index 1f95f14f8..0feab6e7d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,7 +23,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.2", + "navidrome-music-player": "4.25.4", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", @@ -129,6 +129,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1743,6 +1744,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1766,6 +1768,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2471,6 +2474,7 @@ "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz", "integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.3", "ajv": "^6.10.2", @@ -2524,6 +2528,7 @@ "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz", "integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash": "^4.17.15", "object-hash": "^2.0.0" @@ -2539,6 +2544,7 @@ "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2585,6 +2591,7 @@ "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -3308,6 +3315,7 @@ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "license": "MIT", + "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -3360,6 +3368,7 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3381,6 +3390,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz", "integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3550,6 +3560,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3883,6 +3894,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4567,6 +4579,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4942,6 +4955,7 @@ "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -5822,6 +5836,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6405,6 +6420,7 @@ "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -6421,6 +6437,7 @@ "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", "license": "MIT", + "peer": true, "peerDependencies": { "final-form": "^4.20.8" } @@ -6835,6 +6852,7 @@ "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -6948,6 +6966,7 @@ "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -8550,6 +8569,7 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -8593,9 +8613,9 @@ "license": "MIT" }, "node_modules/navidrome-music-player": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.2.tgz", - "integrity": "sha512-k7RXHOOKHeJRCsfmpmQ+TkErndckFfvYMjzwVAKZvViw2PL9ubKWziPfruHZVQr4FiJd2oYKEuTNiWZgAK87CA==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.4.tgz", + "integrity": "sha512-5N7N94aJMIAfKZ0EKFfsHD95q2HBTAnAWyju0JflKnF+2u9Gr3qwnp3ZaaIC2K3z2GA69z0hqK0aOQ40FYsFrw==", "license": "MIT", "dependencies": { "@react-icons/all-files": "^4.1.0", @@ -9298,6 +9318,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9389,6 +9410,7 @@ "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", "license": "MIT", + "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -9803,6 +9825,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9884,6 +9907,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -9956,6 +9980,7 @@ "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -9973,6 +9998,7 @@ "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -10078,6 +10104,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -10113,6 +10140,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10133,6 +10161,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10314,6 +10343,7 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -10323,6 +10353,7 @@ "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", "license": "MIT", + "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } @@ -10582,6 +10613,7 @@ "integrity": "sha512-FAfGj5Ferzyna11iUwGdkYus/Y9d/H75PEpsseP5DZOsEsyPvP/Q7mJiSXhUYSEmyfHPaZyC8EsJCjqzDbtcfg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11511,6 +11543,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11732,6 +11765,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11976,6 +12010,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12100,6 +12135,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12113,6 +12149,7 @@ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", @@ -12632,6 +12669,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12741,6 +12779,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/ui/package.json b/ui/package.json index b440f0595..b02012104 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,7 +32,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.2", + "navidrome-music-player": "4.25.4", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 268d3668d..f5004308b 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -137,8 +137,9 @@ const updateUser = async (params) => { data: userData, }) - // Then handle library associations for non-admin users - if (!userData.isAdmin && libraryIds !== undefined) { + // Then handle library associations for non-admin users. Only admins can call + // this endpoint; for self-edits the server manages library assignments + if (isAdmin() && !userData.isAdmin && libraryIds !== undefined) { await handleUserLibraryAssociation(userId, libraryIds) } diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js new file mode 100644 index 000000000..fbc82f969 --- /dev/null +++ b/ui/src/dataProvider/wrapperDataProvider.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import wrapperDataProvider from './wrapperDataProvider' + +const { mockProvider, mockHttpClient } = vi.hoisted(() => ({ + mockProvider: { + update: vi.fn(), + create: vi.fn(), + getOne: vi.fn(), + }, + mockHttpClient: vi.fn(), +})) + +vi.mock('ra-data-json-server', () => ({ default: () => mockProvider })) +vi.mock('./httpClient', () => ({ default: mockHttpClient })) + +describe('wrapperDataProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mockProvider.update.mockResolvedValue({ data: { id: 'u1' } }) + mockProvider.create.mockResolvedValue({ data: { id: 'u1' } }) + mockHttpClient.mockResolvedValue({ json: [] }) + }) + + describe('update user', () => { + it('sets library associations when an admin edits a non-admin user', async () => { + localStorage.setItem('role', 'admin') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { name: 'Sam', isAdmin: false, libraryIds: [1] }, + }) + + expect(mockProvider.update).toHaveBeenCalledWith( + 'user', + expect.objectContaining({ id: 'u1' }), + ) + expect(mockHttpClient).toHaveBeenCalledWith('/api/user/u1/library', { + method: 'PUT', + body: JSON.stringify({ libraryIds: [1] }), + }) + }) + + it('does not call the admin-only library endpoint when a non-admin edits their own profile', async () => { + localStorage.setItem('role', 'regular') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { + name: 'Sam', + isAdmin: false, + libraryIds: [1], + currentPassword: 'old', + password: 'new', + }, + }) + + expect(mockProvider.update).toHaveBeenCalled() + expect(mockHttpClient).not.toHaveBeenCalled() + }) + + it('does not set library associations when the edited user is an admin', async () => { + localStorage.setItem('role', 'admin') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { name: 'Sam', isAdmin: true, libraryIds: [1] }, + }) + + expect(mockProvider.update).toHaveBeenCalled() + expect(mockHttpClient).not.toHaveBeenCalled() + }) + + it('strips libraryIds from the user update payload', async () => { + localStorage.setItem('role', 'admin') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { name: 'Sam', isAdmin: false, libraryIds: [1] }, + }) + + expect(mockProvider.update).toHaveBeenCalledWith( + 'user', + expect.objectContaining({ + data: { name: 'Sam', isAdmin: false }, + }), + ) + }) + }) +}) diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index 2283dd8bc..d8302a9f9 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -96,9 +96,10 @@ const UserEdit = (props) => { }) permissions === 'admin' ? redirect('/user') : refresh() } catch (error) { - if (error.body.errors) { + if (error?.body?.errors) { return error.body.errors } + notify('ra.page.error', 'warning') } }, [mutate, notify, permissions, redirect, refresh], diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx index 75a9a1ada..1d8290569 100644 --- a/ui/src/user/UserEdit.test.jsx +++ b/ui/src/user/UserEdit.test.jsx @@ -27,6 +27,14 @@ const adminUser = { isAdmin: true, } +const hooks = vi.hoisted(() => ({ + save: null, + mutate: vi.fn(), + notify: vi.fn(), + redirect: vi.fn(), + refresh: vi.fn(), +})) + // Mock React-Admin completely with simpler implementations vi.mock('react-admin', () => ({ Edit: ({ children, title }) => ( @@ -35,9 +43,10 @@ vi.mock('react-admin', () => ({ {children} ), - SimpleForm: ({ children }) => ( -
{children}
- ), + SimpleForm: ({ children, save }) => { + hooks.save = save + return
{children}
+ }, TextInput: ({ source }) => , BooleanInput: ({ source }) => ( @@ -54,10 +63,10 @@ vi.mock('react-admin', () => ({ Typography: ({ children }) =>

{children}

, required: () => () => null, email: () => () => null, - useMutation: () => [vi.fn()], - useNotify: () => vi.fn(), - useRedirect: () => vi.fn(), - useRefresh: () => vi.fn(), + useMutation: () => [hooks.mutate], + useNotify: () => hooks.notify, + useRedirect: () => hooks.redirect, + useRefresh: () => hooks.refresh, usePermissions: () => ({ permissions: 'admin' }), useTranslate: () => (key) => key, })) @@ -127,4 +136,60 @@ describe('', () => { expect(screen.getByTestId('text-input-name')).toBeInTheDocument() expect(screen.getByTestId('text-input-email')).toBeInTheDocument() }) + + describe('save', () => { + beforeEach(() => { + vi.clearAllMocks() + hooks.save = null + }) + + it('notifies success and redirects when the update succeeds', async () => { + hooks.mutate.mockResolvedValue({ data: defaultUser }) + render() + + await hooks.save({ id: 'user1', name: 'New Name' }) + + expect(hooks.notify).toHaveBeenCalledWith( + 'resources.user.notifications.updated', + 'info', + { smart_count: 1 }, + ) + expect(hooks.redirect).toHaveBeenCalledWith('/user') + }) + + it('returns field errors when the update fails validation', async () => { + const fieldErrors = { currentPassword: 'ra.validation.required' } + hooks.mutate.mockRejectedValue({ body: { errors: fieldErrors } }) + render() + + const result = await hooks.save({ id: 'user1' }) + + expect(result).toEqual(fieldErrors) + expect(hooks.notify).not.toHaveBeenCalledWith( + 'resources.user.notifications.updated', + 'info', + { smart_count: 1 }, + ) + }) + + it('notifies an error when the update fails without field errors', async () => { + hooks.mutate.mockRejectedValue(new Error('Forbidden')) + render() + + await hooks.save({ id: 'user1' }) + + expect(hooks.notify).toHaveBeenCalledWith('ra.page.error', 'warning') + expect(hooks.redirect).not.toHaveBeenCalled() + }) + + it('notifies an error when the update rejects with a non-object error', async () => { + hooks.mutate.mockRejectedValue(undefined) + render() + + await hooks.save({ id: 'user1' }) + + expect(hooks.notify).toHaveBeenCalledWith('ra.page.error', 'warning') + expect(hooks.redirect).not.toHaveBeenCalled() + }) + }) }) diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index 9788926d5..ed2374696 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -85,10 +85,11 @@ func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader R go func() { start := time.Now() - cache, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) + cache, sfs, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) fc.mutex.Lock() defer fc.mutex.Unlock() fc.cache = cache + fc.fs = sfs fc.disabled = cache == nil || err != nil log.Info("Finished initializing cache", "cache", fc.name, "maxSize", fc.cacheSize, "elapsedTime", time.Since(start)) fc.ready.Store(true) @@ -109,6 +110,7 @@ type fileCache struct { cacheFolder string maxItems int cache fscache.Cache + fs *spreadFS getReader ReadFunc disabled bool ready atomic.Bool @@ -177,6 +179,7 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { _ = fc.invalidate(ctx, key) } else { log.Trace(ctx, "File successfully stored in cache", "cache", fc.name, "key", key) + fc.markComplete(ctx, key) } }() } @@ -248,7 +251,18 @@ func copyAndClose(w io.WriteCloser, r io.Reader) error { return err } -func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) { +// markComplete records on disk that the entry for key was written in full, +// so it is eligible for adoption after a restart (see spreadFS.Reload). +func (fc *fileCache) markComplete(ctx context.Context, key string) { + if fc.fs == nil { + return + } + if err := fc.fs.MarkComplete(key); err != nil { + log.Warn(ctx, "Error writing cache completion marker", "cache", fc.name, "key", key, err) + } +} + +func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, *spreadFS, error) { size, err := humanize.ParseBytes(cacheSize) if err != nil { log.Error("Invalid cache size. Using default size", "cache", name, "size", cacheSize, @@ -257,7 +271,7 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach } if size == 0 { log.Warn(fmt.Sprintf("%s cache disabled", name)) - return nil, nil + return nil, nil, nil } lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval) @@ -269,15 +283,15 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach fs, err = NewSpreadFS(cacheFolder, 0755) if err != nil { log.Error(fmt.Sprintf("Error initializing %s cache FS", name), err) - return nil, err + return nil, nil, err } ck, err := fscache.NewCacheWithHaunter(fs, h) if err != nil { log.Error(fmt.Sprintf("Error initializing %s cache", name), err) - return nil, err + return nil, nil, err } ck.SetKeyMapper(fs.KeyMapper) - return ck, nil + return ck, fs, nil } diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 9a9a9444f..edcfbc6b9 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -104,6 +104,75 @@ var _ = Describe("File Caches", func() { Expect(called).To(BeTrue()) }) + It("writes a completion marker after a successful cache write", func() { + fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return strings.NewReader("complete-data"), nil + }) + s, err := fc.Get(context.Background(), &testArg{"markme"}) + Expect(err).To(BeNil()) + _, _ = io.ReadAll(s) + _ = s.Close() + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"markme"}).Key()) + Eventually(func() bool { + _, statErr := os.Stat(dataPath + ".complete") + return statErr == nil + }).Should(BeTrue()) + }) + + It("serves a concurrent reader from an in-progress write and marks complete once", func() { + pr, pw := io.Pipe() + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return pr, nil // slow, still-being-produced stream + }) + + // First Get → MISS; the cache starts copying pr into the entry in a goroutine. + s1, err := fc.Get(context.Background(), &testArg{"live"}) + Expect(err).To(BeNil()) + + // Write the first chunk so the entry exists with in-flight bytes, + // but leave the pipe open so the second reader can attach mid-stream. + // io.Pipe writes block until the cache goroutine reads them, giving us + // a deterministic happens-before: the entry is live before we call Get again. + _, err = pw.Write([]byte("hello ")) + Expect(err).To(BeNil()) + + // Second Get while the pipe is still open → attaches to the in-progress entry. + s2, err := fc.Get(context.Background(), &testArg{"live"}) + Expect(err).To(BeNil()) + + // Drain both readers concurrently; they race against the producer below. + ch1 := make(chan []byte, 1) + ch2 := make(chan []byte, 1) + go func() { b, _ := io.ReadAll(s1); ch1 <- b }() + go func() { b, _ := io.ReadAll(s2); ch2 <- b }() + + // Deliver the rest of the stream and close; both draining goroutines must see it. + _, err = pw.Write([]byte("world")) + Expect(err).To(BeNil()) + Expect(pw.Close()).To(Succeed()) + + Expect(string(<-ch1)).To(Equal("hello world")) + Expect(string(<-ch2)).To(Equal("hello world")) + _ = s1.Close() + _ = s2.Close() + + // Exactly one completion marker must appear. + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"live"}).Key()) + Eventually(func() bool { + _, e := os.Stat(dataPath + ".complete") + return e == nil + }).Should(BeTrue()) + + // Steady-state HIT: full data, Cached flag set. + s3, err := fc.Get(context.Background(), &testArg{"live"}) + Expect(err).To(BeNil()) + got3, _ := io.ReadAll(s3) + _ = s3.Close() + Expect(s3.Cached).To(BeTrue()) + Expect(string(got3)).To(Equal("hello world")) + }) + Context("reader errors", func() { When("creating a reader fails", func() { It("does not cache", func() { @@ -138,6 +207,77 @@ var _ = Describe("File Caches", func() { }) }) }) + + Context("crash leftover (issue #5636)", func() { + It("does not serve a partial file left on disk as a complete HIT", func() { + // First init: empties + writes the migration sentinel. + fc1 := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return strings.NewReader("UNUSED"), nil + }) + _ = fc1 + + // Plant a partial file (no marker), simulating a killed process. + sfs, err := NewSpreadFS(filepath.Join(conf.Server.CacheFolder.String(), "test"), 0755) + Expect(err).To(BeNil()) + partialPath := sfs.KeyMapper((&testArg{"track"}).Key()) + Expect(os.MkdirAll(filepath.Dir(partialPath), 0755)).To(Succeed()) + Expect(os.WriteFile(partialPath, []byte("PARTIAL"), 0600)).To(Succeed()) + + // "Restart": a fresh cache over the same folder (sentinel present → strict). + getReaderCalled := false + fc2 := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + getReaderCalled = true + return strings.NewReader("FULL-TRANSCODE"), nil + }) + + s, err := fc2.Get(context.Background(), &testArg{"track"}) + Expect(err).To(BeNil()) + data, _ := io.ReadAll(s) + _ = s.Close() + + Expect(getReaderCalled).To(BeTrue()) // re-transcoded, not served stale + Expect(string(data)).To(Equal("FULL-TRANSCODE")) + }) + }) + + Context("live error path still invalidates", func() { + It("leaves no data file and no marker after a mid-stream reader error", func() { + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return errFakeReader{errors.New("boom")}, nil + }) + s, err := fc.Get(context.Background(), &testArg{"err"}) + Expect(err).To(BeNil()) + _, _ = io.Copy(io.Discard, s) + _ = s.Close() + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"err"}).Key()) + Eventually(func() bool { + _, e1 := os.Stat(dataPath) + _, e2 := os.Stat(dataPath + ".complete") + return os.IsNotExist(e1) && os.IsNotExist(e2) + }).Should(BeTrue()) + }) + + It("does not write a completion marker when the write fails after partial bytes", func() { + // Mimics a transcode that produces real output and then dies: + // the bytes land on disk, but the entry must NOT be marked complete. + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return &partialThenErrReader{data: []byte("PARTIAL-OUTPUT"), err: errors.New("transcoder died")}, nil + }) + s, err := fc.Get(context.Background(), &testArg{"partial"}) + Expect(err).To(BeNil()) + _, _ = io.Copy(io.Discard, s) + _ = s.Close() + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"partial"}).Key()) + // The marker must never appear for a failed write. Give the async + // writer time to finish, then assert the marker stays absent. + Consistently(func() bool { + _, e := os.Stat(dataPath + ".complete") + return os.IsNotExist(e) + }).Should(BeTrue()) + }) + }) }) }) @@ -148,3 +288,23 @@ func (t *testArg) Key() string { return t.s } type errFakeReader struct{ err error } func (e errFakeReader) Read([]byte) (int, error) { return 0, e.err } + +// partialThenErrReader emits data once, then fails — mimicking a transcoder +// that produces some output and then dies mid-stream. +type partialThenErrReader struct { + data []byte + err error + done bool +} + +func (r *partialThenErrReader) Read(p []byte) (int, error) { + if r.done { + return 0, r.err + } + r.done = true + return copy(p, r.data), nil +} + +func fcSpreadFS(fc *fileCache) *spreadFS { + return fc.fs +} diff --git a/utils/cache/spread_fs.go b/utils/cache/spread_fs.go index 281e2bfab..647439790 100644 --- a/utils/cache/spread_fs.go +++ b/utils/cache/spread_fs.go @@ -14,6 +14,9 @@ import ( "github.com/navidrome/navidrome/log" ) +const completeMarkerSuffix = ".complete" +const sentinelName = ".nd-migrated" + type spreadFS struct { root string mode os.FileMode @@ -40,30 +43,83 @@ func NewSpreadFS(dir string, mode os.FileMode) (*spreadFS, error) { } func (sfs *spreadFS) Reload(f func(key string, name string)) error { + // On the first run after upgrade (no sentinel yet), pre-existing files have + // no completion marker. Migrate them instead of discarding them as partials, + // so a user's whole cache isn't wiped. After the sentinel exists, an unmarked + // file is a crash partial and is discarded. + sentinel := filepath.Join(sfs.root, sentinelName) + _, sErr := os.Stat(sentinel) + migrating := os.IsNotExist(sErr) + count := 0 - err := filepath.WalkDir(sfs.root, func(absoluteFilePath string, de fs.DirEntry, err error) error { + err := sfs.walkDataFiles(func(absoluteFilePath string) { + if _, statErr := os.Stat(sfs.markerPath(absoluteFilePath)); statErr != nil { + switch { + case migrating: + if mErr := sfs.MarkComplete(absoluteFilePath); mErr != nil { + log.Warn("Error migrating cache file", "file", absoluteFilePath, mErr) + } + case os.IsNotExist(statErr): + // No completion marker: this is a partial left by a crash. Discard it. + log.Debug("Removing incomplete cache file", "file", absoluteFilePath) + _ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload + return + default: + // Marker may exist but is unreadable (transient I/O, permissions): + // skip adoption without destroying a possibly-valid entry. + log.Warn("Error reading cache completion marker", "file", absoluteFilePath, statErr) + return + } + } + f(absoluteFilePath, absoluteFilePath) + count++ + }) + if err != nil { + return err + } + + log.Debug("Loaded cache", "dir", sfs.root, "numItems", count) + // Only record the migration as done after a clean walk, so a partial walk + // doesn't leave valid-but-unmarked files to be discarded on the next run. + if migrating { + if wErr := os.WriteFile(sentinel, nil, 0600); wErr != nil { + log.Warn("Error writing cache migration sentinel", "file", sentinel, wErr) + } + } + return nil +} + +// walkDataFiles visits every cache data file (named XX/XX/<40-hex>), skipping +// completion markers and opportunistically cleaning up orphaned ones. +func (sfs *spreadFS) walkDataFiles(visit func(absoluteFilePath string)) error { + return filepath.WalkDir(sfs.root, func(absoluteFilePath string, _ fs.DirEntry, err error) error { if err != nil { log.Error("Error loading cache", "dir", sfs.root, err) + return nil } path, err := filepath.Rel(sfs.root, absoluteFilePath) if err != nil { return nil //nolint:nilerr } + // Skip marker files; also clean orphan markers (data file gone). + if strings.HasSuffix(path, completeMarkerSuffix) { + dataPath := strings.TrimSuffix(absoluteFilePath, completeMarkerSuffix) + if _, statErr := os.Stat(dataPath); os.IsNotExist(statErr) { + _ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload + } + return nil + } + // Skip if name is not in the format XX/XX/XXXXXXXXXXXX parts := strings.Split(path, string(os.PathSeparator)) if len(parts) != 3 || len(parts[0]) != 2 || len(parts[1]) != 2 || len(parts[2]) != 40 { return nil } - f(absoluteFilePath, absoluteFilePath) - count++ + visit(absoluteFilePath) return nil }) - if err == nil { - log.Debug("Loaded cache", "dir", sfs.root, "numItems", count) - } - return err } func (sfs *spreadFS) Create(name string) (stream.File, error) { @@ -79,7 +135,27 @@ func (sfs *spreadFS) Open(name string) (stream.File, error) { return os.Open(name) } +func (sfs *spreadFS) markerPath(dataPath string) string { + return dataPath + completeMarkerSuffix +} + +// MarkComplete records that the cache entry for key was written in full. +// Only files with a marker are adopted on the next Reload; this is what +// distinguishes a complete cache entry from a partial one left by a crash. +// key may be an original cache key or an already-mapped data path; KeyMapper +// is idempotent for the latter (see KeyMapper). +func (sfs *spreadFS) MarkComplete(key string) error { + f, err := os.OpenFile(sfs.markerPath(sfs.KeyMapper(key)), os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + return f.Close() +} + func (sfs *spreadFS) Remove(name string) error { + if err := os.Remove(sfs.markerPath(name)); err != nil && !os.IsNotExist(err) { + log.Warn("Error removing cache completion marker", "file", name, err) + } return os.Remove(name) } diff --git a/utils/cache/spread_fs_test.go b/utils/cache/spread_fs_test.go index 2768ea2d5..0f88d3a58 100644 --- a/utils/cache/spread_fs_test.go +++ b/utils/cache/spread_fs_test.go @@ -39,31 +39,93 @@ var _ = Describe("Spread FS", func() { }) }) - Describe("Reload", func() { - var files []string + Describe("MarkComplete / Remove markers", func() { + It("creates a .complete marker for a data file", func() { + data := fs.KeyMapper("song1") + f, err := fs.Create(data) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte("ok")) + _ = f.Close() - BeforeEach(func() { - files = []string{"aaaaa", "bbbbb", "ccccc"} - for _, content := range files { - file := fs.KeyMapper(content) - f, err := fs.Create(file) - Expect(err).To(BeNil()) - _, _ = f.Write([]byte(content)) - _ = f.Close() - } + Expect(fs.MarkComplete(data)).To(Succeed()) + _, statErr := os.Stat(data + ".complete") + Expect(statErr).To(BeNil()) }) - It("loads all files from fs", func() { + It("removes the sibling marker when the data file is removed", func() { + data := fs.KeyMapper("song2") + f, err := fs.Create(data) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte("ok")) + _ = f.Close() + Expect(fs.MarkComplete(data)).To(Succeed()) + + Expect(fs.Remove(data)).To(Succeed()) + _, dataErr := os.Stat(data) + Expect(os.IsNotExist(dataErr)).To(BeTrue()) + _, markErr := os.Stat(data + ".complete") + Expect(os.IsNotExist(markErr)).To(BeTrue()) + }) + }) + + Describe("Reload", func() { + makeData := func(content string) string { + file := fs.KeyMapper(content) + f, err := fs.Create(file) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte(content)) + _ = f.Close() + return file + } + + It("migrates all existing files on first run and writes the sentinel", func() { + for _, c := range []string{"aaaaa", "bbbbb", "ccccc"} { + makeData(c) // no markers, simulating a pre-upgrade cache + } + var actual []string - err := fs.Reload(func(key string, name string) { + err := fs.Reload(func(key, name string) { Expect(key).To(Equal(name)) - data, err := os.ReadFile(name) - Expect(err).To(BeNil()) + data, _ := os.ReadFile(name) actual = append(actual, string(data)) }) Expect(err).To(BeNil()) - Expect(actual).To(HaveLen(len(files))) - Expect(actual).To(ContainElements(files[0], files[1], files[2])) + Expect(actual).To(ContainElements("aaaaa", "bbbbb", "ccccc")) + Expect(actual).To(HaveLen(3)) + + _, sentinelErr := os.Stat(filepath.Join(rootDir, ".nd-migrated")) + Expect(sentinelErr).To(BeNil()) + }) + + It("after migration, adopts only marked files and deletes unmarked partials", func() { + // Pretend migration already happened. + Expect(os.WriteFile(filepath.Join(rootDir, ".nd-migrated"), nil, 0600)).To(Succeed()) + + good := makeData("good") + Expect(fs.MarkComplete(good)).To(Succeed()) + bad := makeData("bad") // partial: no marker + + var actual []string + err := fs.Reload(func(key, name string) { actual = append(actual, name) }) + Expect(err).To(BeNil()) + Expect(actual).To(ConsistOf(good)) + + _, badErr := os.Stat(bad) + Expect(os.IsNotExist(badErr)).To(BeTrue()) // partial deleted + }) + + It("ignores and cleans orphan markers", func() { + Expect(os.WriteFile(filepath.Join(rootDir, ".nd-migrated"), nil, 0600)).To(Succeed()) + orphan := fs.KeyMapper("orphan") + ".complete" + Expect(os.MkdirAll(filepath.Dir(orphan), 0755)).To(Succeed()) + Expect(os.WriteFile(orphan, nil, 0600)).To(Succeed()) + + var actual []string + err := fs.Reload(func(key, name string) { actual = append(actual, name) }) + Expect(err).To(BeNil()) + Expect(actual).To(BeEmpty()) + _, orphanErr := os.Stat(orphan) + Expect(os.IsNotExist(orphanErr)).To(BeTrue()) }) }) }) diff --git a/utils/gg/gg.go b/utils/gg/gg.go index 674cacf20..837f56339 100644 --- a/utils/gg/gg.go +++ b/utils/gg/gg.go @@ -16,3 +16,13 @@ func If[T any](cond bool, v1, v2 T) T { } return v2 } + +// Clone returns a pointer to a fresh copy of *p, or nil if p is nil. Use it to +// avoid aliasing the pointed-to value when a separate *T is needed. +func Clone[T any](p *T) *T { + if p == nil { + return nil + } + v := *p + return &v +} diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go index a2dd8154f..bb6fae867 100644 --- a/utils/gg/gg_test.go +++ b/utils/gg/gg_test.go @@ -46,4 +46,25 @@ var _ = Describe("GG", func() { Expect(gg.If(false, 1.1, 2.2)).To(Equal(2.2)) }) }) + + Describe("Clone", func() { + It("returns a pointer to a copy of the value", func() { + original := 123 + cloned := gg.Clone(&original) + Expect(cloned).To(HaveValue(Equal(123))) + Expect(cloned).NotTo(BeIdenticalTo(&original)) + }) + + It("does not alias the original value", func() { + original := 123 + cloned := gg.Clone(&original) + original = 456 + Expect(*cloned).To(Equal(123)) + }) + + It("returns nil when the input is nil", func() { + var v *int + Expect(gg.Clone(v)).To(BeNil()) + }) + }) })