From cf3dc7c06930cae457ef4d0226bb0ead9f26c525 Mon Sep 17 00:00:00 2001 From: Snow <35224538+KibbeWater@users.noreply.github.com> Date: Tue, 7 Nov 2023 00:52:37 +0100 Subject: [PATCH] feat(segment): add lastfm --- src/engine/segment.go | 3 + src/segments/lastfm.go | 142 +++++++++++++++++++++++++++++++ src/segments/lastfm_test.go | 105 +++++++++++++++++++++++ themes/schema.json | 50 +++++++++++ website/docs/segments/lastfm.mdx | 70 +++++++++++++++ website/sidebars.js | 1 + 6 files changed, 371 insertions(+) create mode 100644 src/segments/lastfm.go create mode 100644 src/segments/lastfm_test.go create mode 100644 website/docs/segments/lastfm.mdx diff --git a/src/engine/segment.go b/src/engine/segment.go index c1acc803..a2f4229a 100644 --- a/src/engine/segment.go +++ b/src/engine/segment.go @@ -168,6 +168,8 @@ const ( KOTLIN SegmentType = "kotlin" // KUBECTL writes the Kubernetes context we're currently in KUBECTL SegmentType = "kubectl" + // LASTFM writes the lastfm status + LASTFM SegmentType = "lastfm" // LUA writes the active lua version LUA SegmentType = "lua" // MERCURIAL writes the Mercurial source control information @@ -299,6 +301,7 @@ var Segments = map[SegmentType]func() SegmentWriter{ JULIA: func() SegmentWriter { return &segments.Julia{} }, KOTLIN: func() SegmentWriter { return &segments.Kotlin{} }, KUBECTL: func() SegmentWriter { return &segments.Kubectl{} }, + LASTFM: func() SegmentWriter { return &segments.LastFM{} }, LUA: func() SegmentWriter { return &segments.Lua{} }, MERCURIAL: func() SegmentWriter { return &segments.Mercurial{} }, NBA: func() SegmentWriter { return &segments.Nba{} }, diff --git a/src/segments/lastfm.go b/src/segments/lastfm.go new file mode 100644 index 00000000..b2f8da22 --- /dev/null +++ b/src/segments/lastfm.go @@ -0,0 +1,142 @@ +package segments + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/jandedobbeleer/oh-my-posh/src/platform" + "github.com/jandedobbeleer/oh-my-posh/src/properties" +) + +type LastFM struct { + props properties.Properties + env platform.Environment + + Artist string + Track string + Full string + Icon string + Status string +} + +const ( + // LastFM username + Username properties.Property = "username" +) + +type lmfDate struct { + UnixString string `json:"uts"` +} + +type lfmTrackInfo struct { + IsPlaying *string `json:"nowplaying,omitempty"` +} + +type Artist struct { + Name string `json:"#text"` +} + +type lfmTrack struct { + Artist `json:"artist"` + Name string `json:"name"` + Info *lfmTrackInfo `json:"@attr"` + Date lmfDate `json:"date"` +} + +type tracks struct { + Tracks []*lfmTrack `json:"track"` +} + +type lfmDataResponse struct { + TracksInfo tracks `json:"recenttracks"` +} + +func (d *LastFM) Enabled() bool { + err := d.setStatus() + + if err != nil { + d.env.Error(err) + return false + } + + return true +} + +func (d *LastFM) Template() string { + return " {{ .Icon }}{{ if ne .Status \"stopped\" }}{{ .Full }}{{ end }} " +} + +func (d *LastFM) getResult() (*lfmDataResponse, error) { + cacheTimeout := d.props.GetInt(properties.CacheTimeout, 0) + response := new(lfmDataResponse) + + apikey := d.props.GetString(APIKey, ".") + username := d.props.GetString(Username, ".") + httpTimeout := d.props.GetInt(properties.HTTPTimeout, properties.DefaultHTTPTimeout) + + url := fmt.Sprintf("https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&api_key=%s&user=%s&format=json&limit=1", apikey, username) + + if cacheTimeout > 0 { + val, found := d.env.Cache().Get(url) + + if found { + err := json.Unmarshal([]byte(val), response) + if err != nil { + return nil, err + } + return response, nil + } + } + + body, err := d.env.HTTPRequest(url, nil, httpTimeout) + if err != nil { + return new(lfmDataResponse), err + } + err = json.Unmarshal(body, &response) + if err != nil { + return new(lfmDataResponse), err + } + + if cacheTimeout > 0 { + d.env.Cache().Set(url, string(body), cacheTimeout) + } + return response, nil +} + +func (d *LastFM) setStatus() error { + q, err := d.getResult() + if err != nil { + return err + } + + if len(q.TracksInfo.Tracks) == 0 { + return errors.New("No data found") + } + + track := q.TracksInfo.Tracks[0] + + d.Artist = track.Artist.Name + d.Track = track.Name + d.Full = fmt.Sprintf("%s - %s", d.Artist, d.Track) + + isPlaying := false + if track.Info != nil && track.Info.IsPlaying != nil && *track.Info.IsPlaying == "true" { + isPlaying = true + } + + if isPlaying { + d.Icon = d.props.GetString(PlayingIcon, "\uE602 ") + d.Status = "playing" + } else { + d.Icon = d.props.GetString(StoppedIcon, "\uF04D ") + d.Status = "stopped" + } + + return nil +} + +func (d *LastFM) Init(props properties.Properties, env platform.Environment) { + d.props = props + d.env = env +} diff --git a/src/segments/lastfm_test.go b/src/segments/lastfm_test.go new file mode 100644 index 00000000..1d9d00ce --- /dev/null +++ b/src/segments/lastfm_test.go @@ -0,0 +1,105 @@ +package segments + +import ( + "errors" + "testing" + + "github.com/jandedobbeleer/oh-my-posh/src/mock" + "github.com/jandedobbeleer/oh-my-posh/src/properties" + + "github.com/stretchr/testify/assert" + mock2 "github.com/stretchr/testify/mock" +) + +const ( + LFMAPIURL = "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&api_key=key&user=KibbeWater&format=json&limit=1" +) + +func TestLFMSegmentSingle(t *testing.T) { + cases := []struct { + Case string + APIJSONResponse string + ExpectedString string + ExpectedEnabled bool + Template string + Error error + }{ + { + Case: "All Defaults", + APIJSONResponse: `{"recenttracks":{"track":[{"artist":{"#text":"C.Gambino"},"name":"Automatic","@attr":{"nowplaying":"true"}}]}}`, + ExpectedString: "\uE602 C.Gambino - Automatic", + ExpectedEnabled: true, + }, + { + Case: "Custom Template", + APIJSONResponse: `{"recenttracks":{"track":[{"artist":{"#text":"C.Gambino"},"name":"Automatic","@attr":{"nowplaying":"true"}}]}}`, + ExpectedString: "\uE602 C.Gambino - Automatic", + ExpectedEnabled: true, + Template: "{{ .Icon }}{{ if ne .Status \"stopped\" }}{{ .Full }}{{ end }}", + }, + { + Case: "Song Stopped", + APIJSONResponse: `{"recenttracks":{"track":[{"artist":{"#text":"C.Gambino"},"name":"Automatic","date":{"uts":"1699350223"}}]}}`, + ExpectedString: "\uF04D", + ExpectedEnabled: true, + Template: "{{ .Icon }}", + }, + { + Case: "Error in retrieving data", + APIJSONResponse: "nonsense", + Error: errors.New("Something went wrong"), + ExpectedEnabled: false, + }, + } + + for _, tc := range cases { + env := &mock.MockedEnvironment{} + var props properties.Map = properties.Map{ + APIKey: "key", + Username: "KibbeWater", + properties.CacheTimeout: 0, + properties.HTTPTimeout: 20000, + } + + env.On("HTTPRequest", LFMAPIURL).Return([]byte(tc.APIJSONResponse), tc.Error) + env.On("Error", mock2.Anything) + + o := &LastFM{ + props: props, + env: env, + } + + enabled := o.Enabled() + assert.Equal(t, tc.ExpectedEnabled, enabled, tc.Case) + if !enabled { + continue + } + + if tc.Template == "" { + tc.Template = o.Template() + } + assert.Equal(t, tc.ExpectedString, renderTemplate(env, tc.Template, o), tc.Case) + } +} + +func TestLFMSegmentFromCache(t *testing.T) { + response := `{"recenttracks":{"track":[{"artist":{"mbid":"","#text":"C.Gambino"},"streamable":"0","name":"Automatic","date":{"uts":"1699350223","#text":"07 Nov 2023, 09:43"}}]}}` + expectedString := "\uF04D" + + env := &mock.MockedEnvironment{} + cache := &mock.MockedCache{} + o := &LastFM{ + props: properties.Map{ + APIKey: "key", + Username: "KibbeWater", + properties.CacheTimeout: 1, + }, + env: env, + } + cache.On("Get", LFMAPIURL).Return(response, true) + cache.On("Set").Return() + env.On("Cache").Return(cache) + + assert.Nil(t, o.setStatus()) + assert.Equal(t, expectedString, renderTemplate(env, o.Template(), o), "should return the cached response") +} diff --git a/themes/schema.json b/themes/schema.json index d2337967..bf90f00a 100644 --- a/themes/schema.json +++ b/themes/schema.json @@ -280,6 +280,7 @@ "java", "kotlin", "kubectl", + "lastfm", "lua", "mercurial", "node", @@ -3155,6 +3156,55 @@ } } }, + { + "if": { + "properties": { + "type": { + "const": "lastfm" + } + } + }, + "then": { + "title": "LastFM Segment", + "description": "https://ohmyposh.dev/docs/segments/lastfm", + "properties": { + "properties": { + "properties": { + "playing_icon": { + "type": "string", + "title": "User Info Separator", + "description": "Text/icon to show when playing", + "default": "\uE602" + }, + "stopped_icon": { + "type": "string", + "title": "SSH Icon", + "description": "Text/icon to show when stopped", + "default": "\uF04D" + }, + "apiKey": { + "type": "string", + "title": "apiKey", + "description": "The apikey used for the api call (Required)", + "default": "." + }, + "username": { + "type": "string", + "title": "username", + "description": "The username used for the api call (Required)", + "default": "." + }, + "http_timeout": { + "$ref": "#/definitions/http_timeout" + }, + "cache_timeout": { + "$ref": "#/definitions/cache_timeout" + } + } + } + } + } + }, { "if": { "properties": { diff --git a/website/docs/segments/lastfm.mdx b/website/docs/segments/lastfm.mdx new file mode 100644 index 00000000..88362ecf --- /dev/null +++ b/website/docs/segments/lastfm.mdx @@ -0,0 +1,70 @@ +--- +id: lastfm +title: LastFM +sidebar_label: LastFM +--- + +## What + +Show the currently playing song from a [LastFM][lastfm] user. + +:::caution +Be aware that LastFM updates may be severely delayed when paused and songs may linger in the "now playing" state for a prolonged time. + +Additionally, we are using HTTP requests to get the data, +so you may need to adjust the `http_timeout` and `cache_timeout` to your liking to get better results. + +You **must** request an [API key][api-key] at the LastFM website. +::: + +## Sample Configuration + +import Config from '@site/src/components/Config.js'; + +", + "username": "", + "http_timeout": 20000, + "cache_timeout": 1 + }, + "style": "powerline", + "template": " {{ .Icon }}{{ if ne .Status \"stopped\" }}{{ .Full }}{{ end }} ", + "type": "lastfm" +}}/> + +## Properties + +| Name | Type | Description | +| -------------- | -------- | ------------------------------------------------------ | +| `playing_icon` | `string` | text/icon to show when playing - defaults to `\uE602 ` | +| `stopped_icon` | `string` | text/icon to show when stopped - defaults to `\uF04D ` | +| `apikey` | `string` | your LastFM [API key][api-key] | +| `username` | `string` | your LastFM username | + +## Template ([info][templates]) + +:::note default template + +```template +{{ .Icon }}{{ if ne .Status \"stopped\" }}{{ .Full }}{{ end }} +``` + +::: + +### Properties + +| Name | Type | Description | +| --------- | -------- | ---------------------------------------------- | +| `.Status` | `string` | player status (`playing`, `paused`, `stopped`) | +| `.Artist` | `string` | current artist | +| `.Track` | `string` | current track | +| `.Full` | `string` | will output `Artist - Track` | +| `.Icon` | `string` | icon (based on `.Status`) | + +[templates]: /docs/configuration/templates +[lastfm]: https://www.last.fm +[api-key]: https://www.last.fm/api/account/create diff --git a/website/sidebars.js b/website/sidebars.js index dc9c1d01..ab659c58 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -88,6 +88,7 @@ module.exports = { "segments/julia", "segments/kotlin", "segments/kubectl", + "segments/lastfm", "segments/lua", "segments/mercurial", "segments/nbgv",