From 39293031d90a4b1f3324d0f8415310d211e99873 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Mon, 9 Mar 2026 17:10:28 -0600 Subject: [PATCH] add custom activity name template with placeholder support --- .gitignore | 3 ++- main.go | 16 +++++++++++--- main_test.go | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 22 ++++++++++++++++++- 4 files changed, 97 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 949626c..ceafef6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.wasm *.ndp -tmp \ No newline at end of file +tmp +.DS_Store diff --git a/main.go b/main.go index be5a81e..8b2cfa1 100644 --- a/main.go +++ b/main.go @@ -25,9 +25,10 @@ import ( // Configuration keys const ( - clientIDKey = "clientid" - usersKey = "users" - activityNameKey = "activityname" + clientIDKey = "clientid" + usersKey = "users" + activityNameKey = "activityname" + activityNameTemplateKey = "activitynametemplate" ) // Activity name display options @@ -36,6 +37,7 @@ const ( activityNameTrack = "Track" activityNameArtist = "Artist" activityNameAlbum = "Album" + activityNameCustom = "Custom" ) // userToken represents a user-token mapping from the config @@ -155,6 +157,14 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { activityName = input.Track.Album case activityNameArtist: activityName = input.Track.Artist + case activityNameCustom: + template, _ := pdk.GetConfig(activityNameTemplateKey) + if template != "" { + activityName = template + activityName = strings.ReplaceAll(activityName, "{track}", input.Track.Title) + activityName = strings.ReplaceAll(activityName, "{artist}", input.Track.Artist) + activityName = strings.ReplaceAll(activityName, "{album}", input.Track.Album) + } } // Send activity update diff --git a/main_test.go b/main_test.go index cd9aa90..864f722 100644 --- a/main_test.go +++ b/main_test.go @@ -230,6 +230,67 @@ var _ = Describe("discordPlugin", func() { Entry("uses track album when configured", "Album", true, "Test Album"), Entry("uses track artist when configured", "Artist", true, "Test Artist"), ) + + DescribeTable("custom activity name template", + func(template string, templateExists bool, expectedName string) { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true) + pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) + pdk.PDKMock.On("GetConfig", activityNameKey).Return("Custom", true) + pdk.PDKMock.On("GetConfig", activityNameTemplateKey).Return(template, templateExists) + + // Connect mocks + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) + gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) + gatewayReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once() + pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once() + host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { + return strings.Contains(url, "gateway.discord.gg") + }), mock.Anything, "testuser").Return("testuser", nil) + + // Capture the activity payload sent to Discord + var sentPayload string + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Run(func(args mock.Arguments) { + sentPayload = args.Get(1).(string) + }).Return(nil) + host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil) + host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) + + // Image mocks + host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { + return strings.HasPrefix(key, "discord.image.") + })).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) + assetsReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool { + return strings.Contains(url, "external-assets") + })).Return(assetsReq) + pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) + host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) + + err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ + Username: "testuser", + Position: 10, + Track: scrobbler.TrackInfo{ + ID: "track1", + Title: "Test Song", + Artist: "Test Artist", + Album: "Test Album", + Duration: 180, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"name":"%s"`, expectedName))) + }, + Entry("uses custom template with all placeholders", "{artist} - {track} ({album})", true, "Test Artist - Test Song (Test Album)"), + Entry("uses custom template with only track", "{track}", true, "Test Song"), + Entry("uses custom template with only artist", "{artist}", true, "Test Artist"), + Entry("uses custom template with only album", "{album}", true, "Test Album"), + Entry("uses custom template with plain text", "Now Playing", true, "Now Playing"), + Entry("falls back to Navidrome when template is empty", "", false, "Navidrome"), + ) }) Describe("Scrobble", func() { diff --git a/manifest.json b/manifest.json index 42cfdd4..a3a5af9 100644 --- a/manifest.json +++ b/manifest.json @@ -55,10 +55,17 @@ "Default", "Track", "Album", - "Artist" + "Artist", + "Custom" ], "default": "Default" }, + "activitynametemplate": { + "type": "string", + "title": "Custom Activity Name Template", + "description": "Template for the activity name. Available placeholders: {track}, {artist}, {album}", + "default": "{artist} - {track}" + }, "uguuenabled": { "type": "boolean", "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", @@ -111,6 +118,19 @@ "format": "radio" } }, + { + "type": "Control", + "scope": "#/properties/activitynametemplate", + "rule": { + "effect": "SHOW", + "condition": { + "scope": "#/properties/activityname", + "schema": { + "const": "Custom" + } + } + } + }, { "type": "Control", "scope": "#/properties/uguuenabled"