feat: youtube music desktop app integration

resolves #170
This commit is contained in:
Kyle Crowley 2020-11-19 22:12:20 -05:00 committed by Jan De Dobbeleer
parent 7071c4d74c
commit 870af53d35
9 changed files with 331 additions and 1 deletions

41
docs/docs/segment-ytm.md Normal file
View file

@ -0,0 +1,41 @@
---
id: ytm
title: YouTube Music
sidebar_label: YouTube Music
---
## What
Shows the currently playing song in the [YouTube Music Desktop App](https://github.com/ytmdesktop/ytmdesktop).
**NOTE**: You **must** enable Remote Control in YTMDA for this segment to work: `Settings > Integrations > Remote Control`
It is fine if `Protect remote control with password` is automatically enabled. This segment does not require the
Remote Control password.
## Sample Configuration
```json
{
"type": "ytm",
"style": "powerline",
"powerline_symbol": "\uE0B0",
"foreground": "#ffffff",
"background": "#FF0000",
"properties": {
"prefix": "\uF16A ",
"playing_icon": "\uE602 ",
"paused_icon": "\uF8E3 ",
"stopped_icon": "\uF04D ",
"track_separator" : " - "
}
}
```
## Properties
- playing_icon: `string` - text/icon to show when playing - defaults to `\uE602 `
- paused_icon: `string` - text/icon to show when paused - defaults to `\uF8E3 `
- stopped_icon: `string` - text/icon to show when paused - defaults to `\uF04D `
- track_separator: `string` - text/icon to put between the artist and song name - defaults to ` - `
- api_url: `string` - the YTMDA Remote Control API URL- defaults to `http://localhost:9863`

View file

@ -35,6 +35,7 @@ module.exports = {
"terraform",
"text",
"time",
"ytm",
]
},
{

View file

@ -3,9 +3,11 @@ package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
@ -43,6 +45,7 @@ type environmentInfo interface {
getBatteryInfo() (*battery.Battery, error)
getShellName() string
getWindowTitle(imageName, windowTitleRegex string) (string, error)
doGet(url string) ([]byte, error)
}
type environment struct {
@ -213,6 +216,23 @@ func (env *environment) getShellName() string {
return strings.Trim(shell, " ")
}
func (env *environment) doGet(url string) ([]byte, error) {
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, err
}
return body, nil
}
func cleanHostName(hostName string) string {
garbage := []string{
".lan",

15
httpclient.go Normal file
View file

@ -0,0 +1,15 @@
package main
import (
"net/http"
)
// Inspired by: https://www.thegreatcodeadventure.com/mocking-http-requests-in-golang/
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
var (
client httpClient = &http.Client{}
)

View file

@ -85,6 +85,8 @@ const (
Plain SegmentStyle = "plain"
// Diamond writes the prompt shaped with a leading and trailing symbol
Diamond SegmentStyle = "diamond"
// YTM writes YouTube Music information and status
YTM SegmentType = "ytm"
)
func (segment *Segment) string() string {
@ -139,6 +141,7 @@ func (segment *Segment) mapSegmentWithWriter(env environmentInfo) error {
Terraform: &terraform{},
Golang: &golang{},
Julia: &julia{},
YTM: &ytm{},
}
if writer, ok := functions[segment.Type]; ok {
props := &properties{

View file

@ -113,6 +113,11 @@ func (env *MockedEnvironment) getWindowTitle(imageName, windowTitleRegex string)
return args.String(0), args.Error(1)
}
func (env *MockedEnvironment) doGet(url string) ([]byte, error) {
args := env.Called(url)
return args.Get(0).([]byte), args.Error(1)
}
const (
homeGates = "/home/gates"
homeBill = "/home/bill"

106
segment_ytm.go Normal file
View file

@ -0,0 +1,106 @@
package main
import (
"encoding/json"
"fmt"
)
type ytm struct {
props *properties
env environmentInfo
status playStatus
artist string
track string
}
const (
// APIURL is the YTMDA Remote Control API URL property.
APIURL Property = "api_url"
)
func (y *ytm) string() string {
icon := ""
separator := y.props.getString(TrackSeparator, " - ")
switch y.status {
case paused:
icon = y.props.getString(PausedIcon, "\uF8E3 ")
case playing:
icon = y.props.getString(PlayingIcon, "\uE602 ")
case stopped:
return y.props.getString(StoppedIcon, "\uF04D ")
}
return fmt.Sprintf("%s%s%s%s", icon, y.artist, separator, y.track)
}
func (y *ytm) enabled() bool {
err := y.setStatus()
// If we don't get a response back (error), the user isn't running
// YTMDA, or they don't have the RC API enabled.
return err == nil
}
func (y *ytm) init(props *properties, env environmentInfo) {
y.props = props
y.env = env
}
type playStatus int
const (
playing playStatus = iota
paused
stopped
)
type ytmdaStatusResponse struct {
player `json:"player"`
track `json:"track"`
}
type player struct {
HasSong bool `json:"hasSong"`
IsPaused bool `json:"isPaused"`
VolumePercent int `json:"volumePercent"`
SeekbarCurrentPosition int `json:"seekbarCurrentPosition"`
SeekbarCurrentPositionHuman string `json:"seekbarCurrentPositionHuman"`
StatePercent float64 `json:"statePercent"`
LikeStatus string `json:"likeStatus"`
RepeatType string `json:"repeatType"`
}
type track struct {
Author string `json:"author"`
Title string `json:"title"`
Album string `json:"album"`
Cover string `json:"cover"`
Duration int `json:"duration"`
DurationHuman string `json:"durationHuman"`
URL string `json:"url"`
ID string `json:"id"`
IsVideo bool `json:"isVideo"`
IsAdvertisement bool `json:"isAdvertisement"`
InLibrary bool `json:"inLibrary"`
}
func (y *ytm) setStatus() error {
// https://github.com/ytmdesktop/ytmdesktop/wiki/Remote-Control-API
url := y.props.getString(APIURL, "http://localhost:9863")
body, err := y.env.doGet(url + "/query")
if err != nil {
return err
}
q := new(ytmdaStatusResponse)
err = json.Unmarshal(body, &q)
if err != nil {
return err
}
y.status = playing
if !q.player.HasSong {
y.status = stopped
} else if q.player.IsPaused {
y.status = paused
}
y.artist = q.track.Author
y.track = q.track.Title
return nil
}

91
segment_ytm_test.go Normal file
View file

@ -0,0 +1,91 @@
package main
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestYTMStringPlayingSong(t *testing.T) {
expected := "\ue602 Candlemass - Spellbreaker"
y := &ytm{
artist: "Candlemass",
track: "Spellbreaker",
status: playing,
}
assert.Equal(t, expected, y.string())
}
func TestYTMStringPausedSong(t *testing.T) {
expected := "\uF8E3 Candlemass - Spellbreaker"
y := &ytm{
artist: "Candlemass",
track: "Spellbreaker",
status: paused,
}
assert.Equal(t, expected, y.string())
}
func TestYTMStringStoppedSong(t *testing.T) {
expected := "\uf04d "
y := &ytm{
artist: "Candlemass",
track: "Spellbreaker",
status: stopped,
}
assert.Equal(t, expected, y.string())
}
func bootstrapYTMDATest(json string, err error) *ytm {
url := "http://localhost:1337"
env := new(MockedEnvironment)
env.On("doGet", url+"/query").Return([]byte(json), err)
props := &properties{
values: map[Property]interface{}{
APIURL: url,
},
}
ytm := &ytm{
env: env,
props: props,
}
return ytm
}
func TestYTMDAPlaying(t *testing.T) {
json := `{ "player": { "hasSong": true, "isPaused": false }, "track": { "author": "Candlemass", "title": "Spellbreaker" } }`
ytm := bootstrapYTMDATest(json, nil)
err := ytm.setStatus()
assert.NoError(t, err)
assert.Equal(t, playing, ytm.status)
assert.Equal(t, "Candlemass", ytm.artist)
assert.Equal(t, "Spellbreaker", ytm.track)
}
func TestYTMDAPaused(t *testing.T) {
json := `{ "player": { "hasSong": true, "isPaused": true }, "track": { "author": "Candlemass", "title": "Spellbreaker" } }`
ytm := bootstrapYTMDATest(json, nil)
err := ytm.setStatus()
assert.NoError(t, err)
assert.Equal(t, paused, ytm.status)
assert.Equal(t, "Candlemass", ytm.artist)
assert.Equal(t, "Spellbreaker", ytm.track)
}
func TestYTMDAStopped(t *testing.T) {
json := `{ "player": { "hasSong": false }, "track": { "author": "", "title": "" } }`
ytm := bootstrapYTMDATest(json, nil)
err := ytm.setStatus()
assert.NoError(t, err)
assert.Equal(t, stopped, ytm.status)
assert.Equal(t, "", ytm.artist)
assert.Equal(t, "", ytm.track)
}
func TestYTMDAError(t *testing.T) {
json := `{ "player": { "hasSong": false }, "track": { "author": "", "title": "" } }`
ytm := bootstrapYTMDATest(json, errors.New("Oh noes"))
enabled := ytm.enabled()
assert.False(t, enabled)
}

View file

@ -103,7 +103,8 @@
"dotnet",
"terraform",
"go",
"julia"
"julia",
"ytm"
]
},
"style": {
@ -1065,6 +1066,53 @@
}
}
}
},
{
"if": {
"properties": {
"type": { "const": "ytm" }
}
},
"then": {
"title": "YouTube Music Desktop App Segment",
"description": "https://ohmyposh.dev/docs/ytm",
"properties": {
"properties": {
"properties": {
"playing_icon": {
"type": "string",
"title": "User Info Separator",
"description": "Text/icon to show when playing",
"default": "\uE602"
},
"paused_icon": {
"type": "string",
"title": "SSH Icon",
"description": "Text/icon to show when paused",
"default": "\uF8E3"
},
"stopped_icon": {
"type": "string",
"title": "SSH Icon",
"description": "Text/icon to show when stopped",
"default": "\uF04D"
},
"track_separator": {
"type": "string",
"title": "SSH Icon",
"description": "Text/icon to put between the artist and song name",
"default": " - "
},
"api_url": {
"type": "string",
"title": "API URL",
"description": "The YTMDA Remote Control API URL",
"default": "http://localhost:9863"
}
}
}
}
}
}
]
}