Skip to content

Commit

Permalink
Initial Narrative / Track Explanation support
Browse files Browse the repository at this point in the history
You can now press 'E' to ask pandora why it selected the currently
playing track.

Fixes #18
  • Loading branch information
nlowe committed Aug 28, 2020
1 parent e6f16f2 commit e35fe91
Show file tree
Hide file tree
Showing 11 changed files with 306 additions and 9 deletions.
13 changes: 13 additions & 0 deletions cmd/uitest.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ func testDataAPI() api.Client {
client.On("GetMoreTracks", mock.Anything).Return(func(_ string) []pandora.Track {
return []pandora.Track{
{
StationId: uuid.Must(uuid.NewRandom()).String(),
MusicId: uuid.Must(uuid.NewRandom()).String(),
TrackToken: uuid.Must(uuid.NewRandom()).String(),
ArtistName: "Test Artist",
AlbumTitle: "Test Album",
Expand All @@ -83,6 +85,17 @@ func testDataAPI() api.Client {
}, nil)
client.On("AddFeedback", mock.Anything, mock.Anything).Return(nil)
client.On("AddTired", mock.Anything).Return(nil)
client.On("GetNarrative", mock.Anything, mock.Anything).Return(pandora.Narrative{
Intro: "Based on what you've told us so far, we're playing this track because it features:",
FocusTraits: []string{
"vocal harmonies",
"mixed acoustic and electric instrumentation",
"minor key tonality",
"mild rhythmic syncopation",
"heavy electric rhythm guitars",
},
Paragraph: "Based on what you've told us so far, we're playing this track because it features vocal harmonies, mixed acoustic and electric instrumentation, minor key tonality, mild rhythmic syncopation and heavy electric rhythm guitars.",
}, nil)

return client
}
Expand Down
23 changes: 22 additions & 1 deletion mocks/Client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion mocks/Player.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions mousiki/station_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ var noStationSelected = pandora.Station{
Name: "No Station Selected",
}

type narrativeCache struct {
station string
track string
narrative pandora.Narrative
}

type StationController struct {
stationLock sync.Mutex
station pandora.Station
Expand All @@ -30,9 +36,15 @@ type StationController struct {
notifications chan *pandora.Track
stationChanged chan pandora.Station

narrativeCache narrativeCache

log logrus.FieldLogger
}

func (n narrativeCache) matches(t *pandora.Track) bool {
return n.station == t.StationId && n.track == t.MusicId
}

func NewStationController(c api.Client, p audio.Player) *StationController {
return &StationController{
pandora: c,
Expand Down Expand Up @@ -192,6 +204,25 @@ func (s *StationController) SwitchStations(station pandora.Station) {
s.stationChanged <- station
}

func (s *StationController) ExplainCurrentTrack() (pandora.Narrative, error) {
if s.narrativeCache.matches(s.playing) {
s.log.Debug("Returning Cached Narrative")
return s.narrativeCache.narrative, nil
}

s.log.Debug("Fetching Narrative")
result, err := s.pandora.GetNarrative(s.playing.StationId, s.playing.MusicId)
if err == nil {
s.narrativeCache = narrativeCache{
station: s.playing.StationId,
track: s.playing.MusicId,
narrative: result,
}
}

return result, err
}

func (s *StationController) StationChanged() <-chan pandora.Station {
return s.stationChanged
}
Expand Down
85 changes: 85 additions & 0 deletions mousiki/station_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mousiki

import (
"context"
"fmt"
"testing"

"github.com/google/uuid"
Expand Down Expand Up @@ -68,3 +69,87 @@ func TestStationController_Play(t *testing.T) {
require.Len(t, played, 3)
require.Equal(t, []string{"1", "2", "3"}, played)
}

func stationControllerTestFunc(f func(t *testing.T, c *mocks.Client, sut *StationController)) func(t *testing.T) {
return func(t *testing.T) {
c := &mocks.Client{}
p := &mocks.Player{}
sut := NewStationController(c, p)
sut.log = testutil.NopLogger()
sut.playing = &pandora.Track{
MusicId: uuid.Must(uuid.NewRandom()).String(),
StationId: uuid.Must(uuid.NewRandom()).String(),
}

f(t, c, sut)
}
}

func TestStationController_ExplainCurrentTrack(t *testing.T) {
t.Run("Valid", stationControllerTestFunc(func(t *testing.T, c *mocks.Client, sut *StationController) {
expected := pandora.Narrative{
Intro: uuid.Must(uuid.NewRandom()).String(),
FocusTraits: []string{uuid.Must(uuid.NewRandom()).String()},
Paragraph: uuid.Must(uuid.NewRandom()).String(),
}

c.On("GetNarrative", mock.Anything, mock.Anything).Return(expected, nil)

result, err := sut.ExplainCurrentTrack()
c.AssertCalled(t, "GetNarrative", sut.playing.StationId, sut.playing.MusicId)
require.NoError(t, err)
require.Equal(t, expected, result)
}))

t.Run("Pandora Error", stationControllerTestFunc(func(t *testing.T, c *mocks.Client, sut *StationController) {
c.On("GetNarrative", mock.Anything, mock.Anything).
Return(pandora.Narrative{}, fmt.Errorf("dummy"))

_, err := sut.ExplainCurrentTrack()
require.EqualError(t, err, "dummy")
}))

t.Run("Caches Track", stationControllerTestFunc(func(t *testing.T, c *mocks.Client, sut *StationController) {
expected := pandora.Narrative{
Intro: uuid.Must(uuid.NewRandom()).String(),
FocusTraits: []string{uuid.Must(uuid.NewRandom()).String()},
Paragraph: uuid.Must(uuid.NewRandom()).String(),
}

c.On("GetNarrative", mock.Anything, mock.Anything).Return(expected, nil)

_, _ = sut.ExplainCurrentTrack()
result, err := sut.ExplainCurrentTrack()
c.AssertCalled(t, "GetNarrative", sut.playing.StationId, sut.playing.MusicId)
c.AssertNumberOfCalls(t, "GetNarrative", 1)
require.NoError(t, err)
require.Equal(t, expected, result)
}))

t.Run("Expires Cache When Track Changes", stationControllerTestFunc(func(t *testing.T, c *mocks.Client, sut *StationController) {
expected := pandora.Narrative{
Intro: uuid.Must(uuid.NewRandom()).String(),
FocusTraits: []string{uuid.Must(uuid.NewRandom()).String()},
Paragraph: uuid.Must(uuid.NewRandom()).String(),
}

c.On("GetNarrative", mock.Anything, mock.Anything).Return(expected, nil)

result, err := sut.ExplainCurrentTrack()
c.AssertCalled(t, "GetNarrative", sut.playing.StationId, sut.playing.MusicId)
require.NoError(t, err)
require.Equal(t, expected, result)

sut.playing = &pandora.Track{
MusicId: uuid.Must(uuid.NewRandom()).String(),
StationId: uuid.Must(uuid.NewRandom()).String(),
}

result, err = sut.ExplainCurrentTrack()
c.AssertCalled(t, "GetNarrative", sut.playing.StationId, sut.playing.MusicId)
require.NoError(t, err)
require.Equal(t, expected, result)

c.AssertNumberOfCalls(t, "GetNarrative", 2)
}))
}
38 changes: 33 additions & 5 deletions mousiki/ui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const pageMain = "main"
type mainWindow struct {
*cview.Pages

stationPicker *stationPicker
stationPicker *stationPicker
narrativePopup *narrativePopup

nowPlaying *pandora.Track
nowPlayingSong *cview.TextView
Expand Down Expand Up @@ -84,6 +85,7 @@ func MainWindow(cancelFunc func(), player audio.Player, controller *mousiki.Stat

root.AddPage(pageMain, grid, true, true)
root.stationPicker = NewStationPickerForPager(cancelFunc, root.Pages, controller)
root.narrativePopup = NewNarrativePopupForPager(cancelFunc, root.Pages, controller)

root.history.ScrollToEnd().
SetDrawFunc(func(_ tcell.Screen, x, y, w, h int) (rx int, ry int, rw int, rh int) {
Expand Down Expand Up @@ -149,20 +151,25 @@ func (w *mainWindow) updateShortcuts() {
w.shortcuts.AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[Q] Quit"), 0, 0, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[ESC] Stations"), 0, 1, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[Space] Play / Pause"), 0, 2, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[N] Next"), 0, 3, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[-] Ban Song"), 0, 4, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[T] Tired Of Song"), 0, 5, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[+] Love Song"), 0, 6, 1, 1, 0, 0, false)
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[E] Explain"), 0, 3, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[N] Next"), 0, 4, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[-] Ban Song"), 0, 5, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[T] Tired Of Song"), 0, 6, 1, 1, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[+] Love Song"), 0, 7, 1, 1, 0, 0, false)
} else if page == stationPickerPageName {
w.shortcuts.AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[Q/ESC] Quit"), 0, 0, 1, 2, 0, 0, false).
AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[Space/Enter] Change Station"), 0, 2, 1, 2, 0, 0, false)
} else if page == narrativePopupPageName {
w.shortcuts.AddItem(cview.NewTextView().SetTextAlign(cview.AlignCenter).SetWrap(false).SetText("[ESC/E] Close"), 0, 2, 1, 1, 0, 0, false)
}
}

func (w *mainWindow) HandleKey(app *cview.Application) func(ev *tcell.EventKey) *tcell.EventKey {
return func(ev *tcell.EventKey) *tcell.EventKey {
if page, _ := w.GetFrontPage(); page == stationPickerPageName {
return w.stationPicker.HandleKey(ev)
} else if page == narrativePopupPageName {
return w.narrativePopup.HandleKey(ev)
}

if ev.Key() == tcell.KeyRune && ev.Rune() == ' ' {
Expand Down Expand Up @@ -202,6 +209,8 @@ func (w *mainWindow) HandleKey(app *cview.Application) func(ev *tcell.EventKey)
if err := w.controller.ProvideFeedback(pandora.TrackRatingBan); err != nil {
w.log.WithError(err).Error("Failed to add feedback")
}
} else if ev.Key() == tcell.KeyRune && ev.Rune() == 'e' {
w.ShowNarrativePopup()
} else {
return ev
}
Expand All @@ -210,14 +219,33 @@ func (w *mainWindow) HandleKey(app *cview.Application) func(ev *tcell.EventKey)
}
}

func intClamp(n, low, high int) int {
if n < low {
return low
}

if n > high {
return high
}

return n
}

func (w *mainWindow) OnResize(width, height int) {
w.stationPicker.Resize(width/2, height/2)

// TODO: Can we grow this automatically based on explanation length?
w.narrativePopup.Resize(intClamp(width/2, 40, 120), intClamp(height/4, 10, 16))
}

func (w *mainWindow) ShowStationPicker() {
w.stationPicker.Open()
}

func (w *mainWindow) ShowNarrativePopup() {
w.narrativePopup.Open()
}

func (w *mainWindow) SyncData(ctx context.Context, app *cview.Application) {
progress := w.player.ProgressChan()
next := w.controller.NotificationChan()
Expand Down
82 changes: 82 additions & 0 deletions mousiki/ui/narrative_popup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package ui

import (
"github.com/gdamore/tcell"
"github.com/nlowe/mousiki/mousiki"
"github.com/sirupsen/logrus"
"gitlab.com/tslocum/cview"
)

const narrativePopupPageName = "narrativePopup"

type narrativePopup struct {
*CenteredModal
root *cview.TextView

cancelFunc func()

controller *mousiki.StationController
pager *cview.Pages

log logrus.FieldLogger
}

func NewNarrativePopupForPager(cancelFunc func(), pager *cview.Pages, controller *mousiki.StationController) *narrativePopup {
result := &narrativePopup{
root: cview.NewTextView(),
cancelFunc: cancelFunc,
controller: controller,
pager: pager,
log: logrus.WithField("prefix", narrativePopupPageName),
}

result.root.SetWrap(true).
SetWordWrap(true).
SetTitle("Explanation").
SetBorder(true).
SetBorderPadding(1, 1, 1, 1)

result.CenteredModal = NewCenteredModal(result.root)

pager.AddPage(narrativePopupPageName, result, true, false)
return result
}

func (n *narrativePopup) Open() {
if page, _ := n.pager.GetFrontPage(); page == narrativePopupPageName {
return
}

if n.controller.NowPlaying() == nil {
n.log.Debug("No Track is playing")
return
}

n.log.Info("Fetching Track Narrative")
narrative, err := n.controller.ExplainCurrentTrack()
if err != nil {
n.log.WithError(err).Errorf("Failed to explain current track")
return
}

n.root.SetText(narrative.Paragraph)
n.pager.ShowPage(narrativePopupPageName)
}

func (n *narrativePopup) Close() {
if page, _ := n.pager.GetFrontPage(); page != narrativePopupPageName {
return
}

n.pager.HidePage(narrativePopupPageName)
}

func (n *narrativePopup) HandleKey(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() == tcell.KeyEscape || (ev.Key() == tcell.KeyRune && ev.Rune() == 'e') {
n.Close()

return nil
}

return ev
}
Loading

0 comments on commit e35fe91

Please sign in to comment.