diff --git a/.direnv/flake-profile b/.direnv/flake-profile new file mode 120000 index 0000000..0c05709 --- /dev/null +++ b/.direnv/flake-profile @@ -0,0 +1 @@ +flake-profile-1-link \ No newline at end of file diff --git a/.direnv/flake-profile-1-link b/.direnv/flake-profile-1-link new file mode 120000 index 0000000..815d486 --- /dev/null +++ b/.direnv/flake-profile-1-link @@ -0,0 +1 @@ +/nix/store/vvmz1kzp0iwsxsmlh0ss6snzn6mx9g4j-nix-shell-env \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7988081..d44acd3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ *.wasm *.ndp tmp + discordrome +.DS_Store diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..03cb250 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1775036866, + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/main.go b/main.go index f149285..5017807 100644 --- a/main.go +++ b/main.go @@ -25,12 +25,13 @@ import ( // Configuration keys const ( - clientIDKey = "clientid" - usersKey = "users" - activityNameKey = "activityname" - spotifyLinksKey = "spotifylinks" - caaEnabledKey = "caaenabled" - uguuEnabledKey = "uguuenabled" + clientIDKey = "clientid" + usersKey = "users" + activityNameKey = "activityname" + activityNameTemplateKey = "activitynametemplate" + spotifyLinksKey = "spotifylinks" + caaEnabledKey = "caaenabled" + uguuEnabledKey = "uguuenabled" ) const ( @@ -47,6 +48,7 @@ const ( activityNameTrack = "Track" activityNameArtist = "Artist" activityNameAlbum = "Album" + activityNameCustom = "Custom" ) // userToken represents a user-token mapping from the config @@ -170,6 +172,16 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { case activityNameArtist: activityName = input.Track.Artist statusDisplayType = statusDisplayName + case activityNameCustom: + template, _ := pdk.GetConfig(activityNameTemplateKey) + if template != "" { + r := strings.NewReplacer( + "{track}", input.Track.Title, + "{artist}", input.Track.Artist, + "{album}", input.Track.Album, + ) + activityName = r.Replace(template) + } } // Resolve Spotify URLs if enabled diff --git a/main_test.go b/main_test.go index 0f7f2b2..51a45d1 100644 --- a/main_test.go +++ b/main_test.go @@ -225,6 +225,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 ebef757..520a4d7 100644 --- a/manifest.json +++ b/manifest.json @@ -51,9 +51,15 @@ "type": "string", "title": "Activity Name Display", "description": "Choose what to display as the activity name in Discord Rich Presence", - "enum": ["Default", "Track", "Album", "Artist"], + "enum": ["Default", "Track", "Album", "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}" + }, "caaenabled": { "type": "boolean", "title": "Use artwork from Cover Art Archive (for MusicBrainz-tagged music)", @@ -112,6 +118,19 @@ "format": "radio" } }, + { + "type": "Control", + "scope": "#/properties/activitynametemplate", + "rule": { + "effect": "SHOW", + "condition": { + "scope": "#/properties/activityname", + "schema": { + "const": "Custom" + } + } + } + }, { "type": "Control", "scope": "#/properties/caaenabled"