Repository: JanDeDobbeleer/oh-my-posh
Pull request #4449 (2023-11-09 16:32:05)

feat lastfm
- feat(segment): add lastfm
- refactor: use the same string for API key properties
- refactor: make true string constant
- chore: ignore generated project files

### Prerequisites

- [x] I have read and understood the [contributing guide][CONTRIBUTING.md].
- [x] The commit message follows the [conventional commits][cc] guidelines.
- [x] Tests for the changes have been added (for bug fixes / features).
- [x] Docs have been added/updated (for bug fixes / features).

### Description

<!--
copilot:summary
-->
### <samp>🤖 Generated by Copilot at 030be2d</samp>

This pull request adds a new `lastfm` segment to show the LastFM status in the prompt. It also improves the `owm` and `brewfather` segments, the `properties` package, and the JSON schema for the themes. It updates the code, tests, and documentation accordingly.

### How

<!--
copilot:walkthrough
-->
### <samp>🤖 Generated by Copilot at 030be2d</samp>

*  Add a new `LastFM` segment to display the LastFM status ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-cd158856c1b1aa6b2acece8041eb07352e4247b700e0fc62798a6be35d5f1c7dR171-R172), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-cd158856c1b1aa6b2acece8041eb07352e4247b700e0fc62798a6be35d5f1c7dR304), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-6f7d43e579a98e91db58753b317153e97c5575c5309d5bdf830c041d9eca30d2R1-R142), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-2e01243ba79b938af962d20d0af5cb823800dc45322589c66c8570cdd6e89370R1-R105), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-892c540c9bfebcb3bb8a0be1714201cd17eb6f0d40367c09f80d56e883b7335eR283), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-892c540c9bfebcb3bb8a0be1714201cd17eb6f0d40367c09f80d56e883b7335eR3163-R3211), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-b6a6f803757e4622af15a7daab25792b91330174f024d5e10f4ec5d7bfae49afR1-R72), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-4caaf3187919c98f42856c140e32964b0829fc19165f3c989a6cd4ea088a7164R91))
*  Add a new `Get` method to the `Properties` interface and implement it for the `Map` and `Wrapper` types ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-4520f5edb1858f47eb8d0587569b2c946ce24059b37775b3e3aff2f58c0bdc5fR18), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-4520f5edb1858f47eb8d0587569b2c946ce24059b37775b3e3aff2f58c0bdc5fR162-R170), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-490aed9c11126989dc620e836310e04039595e74fbd20846005d677fe12f09d7R55-R60))
*  Add a new generic `OneOf` function to the `properties` package to get the first property value that matches a type ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-4520f5edb1858f47eb8d0587569b2c946ce24059b37775b3e3aff2f58c0bdc5fR220-R241), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-0044a9a19ca72098e39ba3b1f785dccd5badeabaa8c933f201f9e09a5d1cf863R114-R167))
*  Rename the `BFAPIKey` constant to the generic `APIKey` constant and use it in the `Brewfather` and `LastFM` segments ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-0d5a4e056c2a6d67352d66cd8ce68c665c84ce888b20bca81b610312ade2e43aL37), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-0d5a4e056c2a6d67352d66cd8ce68c665c84ce888b20bca81b610312ade2e43aL231-R230), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-2e01243ba79b938af962d20d0af5cb823800dc45322589c66c8570cdd6e89370R1-R105), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-892c540c9bfebcb3bb8a0be1714201cd17eb6f0d40367c09f80d56e883b7335eL2282-R2286), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-892c540c9bfebcb3bb8a0be1714201cd17eb6f0d40367c09f80d56e883b7335eR3163-R3211), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-b6a6f803757e4622af15a7daab25792b91330174f024d5e10f4ec5d7bfae49afR1-R72), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-a201377cc6b05439129794f496695fe474856daa7a2a0cdaa3056442ad903140L18-R36), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-a201377cc6b05439129794f496695fe474856daa7a2a0cdaa3056442ad903140L40-R42))
*  Rename the `APIKey` constant from `apikey` to `api_key` in the `OWM` segment and use the `OneOf` function to accept both names ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-f3466da260bee7c26f73ce616c897cbbd74feea39e1005bfbd08d24ce4b30660L26-R26), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-f3466da260bee7c26f73ce616c897cbbd74feea39e1005bfbd08d24ce4b30660L92-R92), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-892c540c9bfebcb3bb8a0be1714201cd17eb6f0d40367c09f80d56e883b7335eL2282-R2286), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-892c540c9bfebcb3bb8a0be1714201cd17eb6f0d40367c09f80d56e883b7335eL2291-R2292), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-a201377cc6b05439129794f496695fe474856daa7a2a0cdaa3056442ad903140L18-R36), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-a201377cc6b05439129794f496695fe474856daa7a2a0cdaa3056442ad903140L40-R42))
*  Add a new constant `trueStr` to the `segments` package and use it in the `Git` segment ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-5ca08bba45ba772ce1cbd182429db15292b228ac7c5dc0cf370dcbe55611fd92R116-R117), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-5ca08bba45ba772ce1cbd182429db15292b228ac7c5dc0cf370dcbe55611fd92L281-R283), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-7290b1c5198130d19c091496b4368edbcfd2b10d49813e6f92f01fbe26a56122L168-R168), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-7290b1c5198130d19c091496b4368edbcfd2b10d49813e6f92f01fbe26a56122L179-R179))
*  Capitalize the word `API` in the description of the `location` property in the JSON schema for the `owm` segment ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4449/files?diff=unified&w=0#diff-892c540c9bfebcb3bb8a0be1714201cd17eb6f0d40367c09f80d56e883b7335eL2291-R2292))

<!---

Tips:

If you're not comfortable with working with Git,
we're working a guide (https://ohmyposh.dev/docs/contributing_git) to help you out.
Oh My Posh advises GitKraken (https://www.gitkraken.com/invite/nQmDPR9D) as your preferred cross platform Git GUI power tool.

-->

[CONTRIBUTING.md]: https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/CONTRIBUTING.md
[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary


JanDeDobbeleer: @KibbeWater as I couldn't force push your main branch, I made a new branch here to add some changes.

Patch:
diff --git a/.gitignore b/.gitignore
index 941e2d0f..7fdd9fb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 # Others
 
 .fleet/
+src/test/umbraco/obj/
 
 # Created by https://www.toptal.com/developers/gitignore/api/node,go,visualstudiocode
 # Edit at https://www.toptal.com/developers/gitignore?templates=node,go,visualstudiocode
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/properties/map.go b/src/properties/map.go
index ecc4007b..5d38565c 100644
--- a/src/properties/map.go
+++ b/src/properties/map.go
@@ -15,6 +15,7 @@ type Properties interface {
 	GetInt(property Property, defaultValue int) int
 	GetKeyValueMap(property Property, defaultValue map[string]string) map[string]string
 	GetStringArray(property Property, defaultValue []string) []string
+	Get(property Property, defaultValue any) any
 }
 
 // Property defines one property of a segment for context
@@ -158,6 +159,15 @@ func (m Map) GetStringArray(property Property, defaultValue []string) []string {
 	return keyValues
 }
 
+func (m Map) Get(property Property, defaultValue any) any {
+	val, found := m[property]
+	if !found {
+		return defaultValue
+	}
+
+	return val
+}
+
 func ParseStringArray(param any) []string {
 	switch v := param.(type) {
 	default:
@@ -207,3 +217,25 @@ func parseKeyValueArray(param any) map[string]string {
 		return v
 	}
 }
+
+// Generic functions
+
+type Value interface {
+	string | int | []string | float64 | bool
+}
+
+func OneOf[T Value](properties Properties, defaultValue T, props ...Property) T {
+	for _, prop := range props {
+		// get value on a generic get, then see if we can cast to T?
+		val := properties.Get(prop, nil)
+		if val == nil {
+			continue
+		}
+
+		if v, ok := val.(T); ok {
+			return v
+		}
+	}
+
+	return defaultValue
+}
diff --git a/src/properties/properties.go b/src/properties/properties.go
index 8d0a3405..708ea377 100644
--- a/src/properties/properties.go
+++ b/src/properties/properties.go
@@ -52,3 +52,9 @@ func (w *Wrapper) GetStringArray(property Property, defaultValue []string) []str
 	w.Env.Debug(fmt.Sprintf("%s: %v", property, value))
 	return value
 }
+
+func (w *Wrapper) Get(property Property, defaultValue any) any {
+	value := w.Properties.Get(property, defaultValue)
+	w.Env.Debug(fmt.Sprintf("%s: %v", property, value))
+	return value
+}
diff --git a/src/segments/brewfather.go b/src/segments/brewfather.go
index 6dac12be..74b16524 100644
--- a/src/segments/brewfather.go
+++ b/src/segments/brewfather.go
@@ -34,7 +34,6 @@ type Brewfather struct {
 
 const (
 	BFUserID  properties.Property = "user_id"
-	BFAPIKey  properties.Property = "api_key"
 	BFBatchID properties.Property = "batch_id"
 
 	BFDoubleUpIcon      properties.Property = "doubleup_icon"
@@ -228,7 +227,7 @@ func (bf *Brewfather) getResult() (*Batch, error) {
 		return nil, errors.New("missing Brewfather user id (user_id)")
 	}
 
-	apiKey := bf.props.GetString(BFAPIKey, "")
+	apiKey := bf.props.GetString(APIKey, "")
 	if len(apiKey) == 0 {
 		return nil, errors.New("missing Brewfather api key (api_key)")
 	}
diff --git a/src/segments/git.go b/src/segments/git.go
index d72ea0e8..e23e019a 100644
--- a/src/segments/git.go
+++ b/src/segments/git.go
@@ -113,6 +113,8 @@ const (
 	DETACHED     = "(detached)"
 	BRANCHPREFIX = "ref: refs/heads/"
 	GITCOMMAND   = "git"
+
+	trueStr = "true"
 )
 
 type Git struct {
@@ -278,7 +280,7 @@ func (g *Git) shouldDisplay() bool {
 		}
 		g.realDir = g.env.Pwd()
 		bare := g.getGitCommandOutput("rev-parse", "--is-bare-repository")
-		if bare == "true" {
+		if bare == trueStr {
 			g.IsBare = true
 			g.workingDir = g.realDir
 			return true
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/owm.go b/src/segments/owm.go
index 7a2967c1..9572c5c1 100644
--- a/src/segments/owm.go
+++ b/src/segments/owm.go
@@ -23,7 +23,7 @@ type Owm struct {
 
 const (
 	// APIKey openweathermap api key
-	APIKey properties.Property = "apikey"
+	APIKey properties.Property = "api_key"
 	// Location openweathermap location
 	Location properties.Property = "location"
 	// Units openweathermap units
@@ -89,7 +89,7 @@ func (d *Owm) getResult() (*owmDataResponse, error) {
 		}
 	}
 
-	apikey := d.props.GetString(APIKey, ".")
+	apikey := properties.OneOf[string](d.props, ".", APIKey, "apiKey")
 	location := d.props.GetString(Location, "De Bilt,NL")
 	latitude := d.props.GetFloat64(Latitude, 91)    // This default value is intentionally invalid since there should not be a default for this and 0 is a valid value
 	longitude := d.props.GetFloat64(Longitude, 181) // This default value is intentionally invalid since there should not be a default for this and 0 is a valid value
diff --git a/themes/schema.json b/themes/schema.json
index d2337967..cc630e16 100644
--- a/themes/schema.json
+++ b/themes/schema.json
@@ -280,6 +280,7 @@
             "java",
             "kotlin",
             "kubectl",
+            "lastfm",
             "lua",
             "mercurial",
             "node",
@@ -2279,16 +2280,16 @@
             "properties": {
               "properties": {
                 "properties": {
-                  "apikey": {
+                  "api_key": {
                     "type": "string",
-                    "title": "apikey",
-                    "description": "The apikey used for the api call (Required)",
+                    "title": "API key",
+                    "description": "The API key used for the api call (Required)",
                     "default": "."
                   },
                   "location": {
                     "type": "string",
                     "title": "location",
-                    "description": "Location to use for the api call. Formatted as <City>,<STATE>,<COUNTRY_CODE>. City name, state code and country code divided by comma. Please, refer to ISO 3166 for the state codes or country codes.",
+                    "description": "Location to use for the API call. Formatted as <City>,<STATE>,<COUNTRY_CODE>. City name, state code and country code divided by comma. Please, refer to ISO 3166 for the state codes or country codes.",
                     "default": "De Bilt,NL"
                   },
                   "units": {
@@ -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"
+                  },
+                  "api_key": {
+                    "type": "string",
+                    "title": "API key",
+                    "description": "The API key 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..c6722224
--- /dev/null
+++ b/website/docs/segments/lastfm.mdx
@@ -0,0 +1,72 @@
+---
+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";
+
+<Config
+  data={{
+    background: "p:sky",
+    foreground: "p:white",
+    powerline_symbol: "\ue0b0",
+    properties: {
+      api_key: "<YOUR_API_KEY>",
+      username: "<LASTFM_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 ` |
+| `api_key`      | `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/docs/segments/owm.mdx b/website/docs/segments/owm.mdx
index 305263ec..ceece136 100644
--- a/website/docs/segments/owm.mdx
+++ b/website/docs/segments/owm.mdx
@@ -15,29 +15,31 @@ The free tier for _Current weather and forecasts collection_ is sufficient.
 
 ## Sample Configuration
 
-import Config from '@site/src/components/Config.js';
-
-<Config data={{
-  "type": "owm",
-  "style": "powerline",
-  "powerline_symbol": "\uE0B0",
-  "foreground": "#ffffff",
-  "background": "#FF0000",
-  "template": "{{.Weather}} ({{.Temperature}}{{.UnitIcon}})",
-  "properties": {
-    "apikey": "<YOUR_API_KEY>",
-    "location": "AMSTERDAM,NL",
-    "units": "metric",
-    "http_timeout": 20,
-    "cache_timeout": 10
-  }
-}}/>
+import Config from "@site/src/components/Config.js";
+
+<Config
+  data={{
+    type: "owm",
+    style: "powerline",
+    powerline_symbol: "\uE0B0",
+    foreground: "#ffffff",
+    background: "#FF0000",
+    template: "{{.Weather}} ({{.Temperature}}{{.UnitIcon}})",
+    properties: {
+      api_key: "<YOUR_API_KEY>",
+      location: "AMSTERDAM,NL",
+      units: "metric",
+      http_timeout: 20,
+      cache_timeout: 10,
+    },
+  }}
+/>
 
 ## Properties
 
 | Name            | Type      | Description                                                                                                                                                                                                        |
 | --------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `apikey`        | `string`  | Your API key from [Open Weather Map][owm]                                                                                                                                                                          |
+| `api_key`       | `string`  | Your API key from [Open Weather Map][owm]                                                                                                                                                                          |
 | `latitude`      | `float64` | The latitude of the requested location.                                                                                                                                                                            |
 | `longitude`     | `float64` | The longitude of the requested location.                                                                                                                                                                           |
 | `location`      | `string`  | The requested location. Formatted as <City,STATE,COUNTRY_CODE>. City name, state code and country code divided by comma. Please, refer to ISO 3166 for the state codes or country codes - defaults to `DE BILT,NL` |
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",


Test patch:
diff --git a/src/properties/map_test.go b/src/properties/map_test.go
index f8d5b1a5..7e8582b6 100644
--- a/src/properties/map_test.go
+++ b/src/properties/map_test.go
@@ -111,3 +111,57 @@ func TestGetFloat64PropertyNotInMap(t *testing.T) {
 	value := properties.GetFloat64(Foo, expected)
 	assert.Equal(t, expected, value)
 }
+
+func TestOneOf(t *testing.T) {
+	cases := []struct {
+		Case         string
+		Map          Map
+		Properties   []Property
+		DefaultValue string
+		Expected     any
+	}{
+		{
+			Case:       "one element",
+			Expected:   "1337",
+			Properties: []Property{Foo},
+			Map: Map{
+				Foo: "1337",
+			},
+			DefaultValue: "2000",
+		},
+		{
+			Case:       "two elements",
+			Expected:   "1337",
+			Properties: []Property{Foo},
+			Map: Map{
+				Foo:   "1337",
+				"Bar": "9001",
+			},
+			DefaultValue: "2000",
+		},
+		{
+			Case:       "no match",
+			Expected:   "2000",
+			Properties: []Property{"Moo"},
+			Map: Map{
+				Foo:   "1337",
+				"Bar": "9001",
+			},
+			DefaultValue: "2000",
+		},
+		{
+			Case:       "incorrect type",
+			Expected:   "2000",
+			Properties: []Property{Foo},
+			Map: Map{
+				Foo:   1337,
+				"Bar": "9001",
+			},
+			DefaultValue: "2000",
+		},
+	}
+	for _, tc := range cases {
+		value := OneOf(tc.Map, tc.DefaultValue, tc.Properties...)
+		assert.Equal(t, tc.Expected, value, tc.Case)
+	}
+}
diff --git a/src/segments/brewfather_test.go b/src/segments/brewfather_test.go
index a394198a..8b8dd3e3 100644
--- a/src/segments/brewfather_test.go
+++ b/src/segments/brewfather_test.go
@@ -148,7 +148,7 @@ func TestBrewfatherSegment(t *testing.T) {
 		props := properties.Map{
 			properties.CacheTimeout: tc.CacheTimeout,
 			BFBatchID:               BFFakeBatchID,
-			BFAPIKey:                "FAKE",
+			APIKey:                  "FAKE",
 			BFUserID:                "FAKE",
 		}
 
diff --git a/src/segments/git_test.go b/src/segments/git_test.go
index 5a95be67..f25135e8 100644
--- a/src/segments/git_test.go
+++ b/src/segments/git_test.go
@@ -165,7 +165,7 @@ func TestEnabledInBareRepo(t *testing.T) {
 	}{
 		{
 			Case:            "Bare repo on main",
-			IsBare:          "true",
+			IsBare:          trueStr,
 			HEAD:            "ref: refs/heads/main",
 			ExpectedEnabled: true,
 			ExpectedHEAD:    "main",
@@ -176,7 +176,7 @@ func TestEnabledInBareRepo(t *testing.T) {
 		},
 		{
 			Case:            "Bare repo on main remote enabled",
-			IsBare:          "true",
+			IsBare:          trueStr,
 			HEAD:            "ref: refs/heads/main",
 			ExpectedEnabled: true,
 			ExpectedHEAD:    "main",
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")
+}