Skip to content

Commit

Permalink
feat: youtube music desktop app integration
Browse files Browse the repository at this point in the history
  • Loading branch information
KyleCrowley authored and JanDeDobbeleer committed Dec 1, 2020
1 parent 7071c4d commit 870af53
Show file tree
Hide file tree
Showing 9 changed files with 331 additions and 1 deletion.
41 changes: 41 additions & 0 deletions docs/docs/segment-ytm.md
Original file line number Diff line number Diff line change
@@ -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`
1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
"terraform",
"text",
"time",
"ytm",
]
},
{
Expand Down
20 changes: 20 additions & 0 deletions environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions httpclient.go
Original file line number Diff line number Diff line change
@@ -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{}
)
3 changes: 3 additions & 0 deletions segment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down
5 changes: 5 additions & 0 deletions segment_path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
106 changes: 106 additions & 0 deletions segment_ytm.go
Original file line number Diff line number Diff line change
@@ -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 changes: 91 additions & 0 deletions segment_ytm_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 870af53

Please sign in to comment.