diff --git a/.gitignore b/.gitignore index b97dcc4..76dcd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.wasm *.ndp tmp -discord-rich-presence \ No newline at end of file +discord-rich-presence +.DS_Store diff --git a/main.go b/main.go index 52871f8..e2286e6 100644 --- a/main.go +++ b/main.go @@ -25,10 +25,11 @@ import ( // Configuration keys const ( - clientIDKey = "clientid" - usersKey = "users" - activityNameKey = "activityname" - spotifyLinksKey = "spotifylinks" + clientIDKey = "clientid" + usersKey = "users" + activityNameKey = "activityname" + activityNameTemplateKey = "activitynametemplate" + spotifyLinksKey = "spotifylinks" ) const ( @@ -45,6 +46,7 @@ const ( activityNameTrack = "Track" activityNameArtist = "Artist" activityNameAlbum = "Album" + activityNameCustom = "Custom" ) // userToken represents a user-token mapping from the config @@ -168,6 +170,14 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { case activityNameArtist: activityName = input.Track.Artist statusDisplayType = statusDisplayName + 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) + } } // Resolve Spotify URLs if enabled diff --git a/main_test.go b/main_test.go index ce399fb..9031f2e 100644 --- a/main_test.go +++ b/main_test.go @@ -223,6 +223,62 @@ var _ = Describe("discordPlugin", func() { Entry("uses track album when configured", "Album", true, "Test Album", 0), Entry("uses track artist when configured", "Artist", true, "Test Artist", 0), ) + + 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) + pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false) + + // Connect mocks + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) + gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) + 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", discordImageKey).Return("", false, nil) + host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) + host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil) + 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 ec9304a..b9ac0c1 100644 --- a/manifest.json +++ b/manifest.json @@ -56,10 +56,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)", @@ -118,6 +125,19 @@ "format": "radio" } }, + { + "type": "Control", + "scope": "#/properties/activitynametemplate", + "rule": { + "effect": "SHOW", + "condition": { + "scope": "#/properties/activityname", + "schema": { + "const": "Custom" + } + } + } + }, { "type": "Control", "scope": "#/properties/uguuenabled"