diff --git a/README.md b/README.md index c234061..922dd0f 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,5 @@ host = 'https://your-subsonic-host.tld' * n - Continue search forward * N - Continue search backwards * r - refresh the list (if in artist directory, only refreshes that artist) +* s - add 50 random songs to the queue +* y - toggle star on song diff --git a/api.go b/api.go index 63b4d43..234368f 100644 --- a/api.go +++ b/api.go @@ -74,6 +74,14 @@ type SubsonicDirectory struct { Entities SubsonicEntities `json:"child"` } +type SubsonicSongs struct { + Song SubsonicEntities `json:"song"` +} + +type SubsonicStarred struct { + Starred SubsonicEntities `json:"starred"` +} + type SubsonicEntity struct { Id string `json:"id"` IsDirectory bool `json:"isDir"` @@ -130,13 +138,15 @@ type SubsonicPlaylist struct { } type SubsonicResponse struct { - Status string `json:"status"` - Version string `json:"version"` - Indexes SubsonicIndexes `json:"indexes"` - Directory SubsonicDirectory `json:"directory"` - Playlists SubsonicPlaylists `json:"playlists"` - Playlist SubsonicPlaylist `json:"playlist"` - Error SubsonicError `json:"error"` + Status string `json:"status"` + Version string `json:"version"` + Indexes SubsonicIndexes `json:"indexes"` + Directory SubsonicDirectory `json:"directory"` + RandomSongs SubsonicSongs `json:"randomSongs"` + Starred SubsonicSongs `json:"starred"` + Playlists SubsonicPlaylists `json:"playlists"` + Playlist SubsonicPlaylist `json:"playlist"` + Error SubsonicError `json:"error"` } type responseWrapper struct { @@ -192,6 +202,52 @@ func (connection *SubsonicConnection) GetMusicDirectory(id string) (*SubsonicRes return resp, nil } +func (connection *SubsonicConnection) GetRandomSongs() (*SubsonicResponse, error) { + query := defaultQuery(connection) + // Let's get 50 random songs, default is 10 + query.Set("size", "50") + requestUrl := connection.Host + "/rest/getRandomSongs" + "?" + query.Encode() + resp, err := connection.getResponse("GetRandomSongs", requestUrl) + if err != nil { + return resp, err + } + return resp, nil +} + +func (connection *SubsonicConnection) GetStarred() (*SubsonicResponse, error) { + query := defaultQuery(connection) + requestUrl := connection.Host + "/rest/getStarred" + "?" + query.Encode() + resp, err := connection.getResponse("GetStarred", requestUrl) + if err != nil { + return resp, err + } + return resp, nil +} + +func (connection *SubsonicConnection) ToggleStar(id string, starredItems map[string]struct{}) (*SubsonicResponse, error) { + query := defaultQuery(connection) + query.Set("id",id) + + _, ok := starredItems[id] + var action = "star" + // If the key exists, we're unstarring + if ok { + action = "unstar" + } + + requestUrl := connection.Host + "/rest/" + action + "?" + query.Encode() + resp, err := connection.getResponse("ToggleStar", requestUrl) + if err != nil { + if (ok) { + delete(starredItems, id) + } else { + starredItems[id] = struct{}{} + } + return resp, err + } + return resp, nil +} + func (connection *SubsonicConnection) GetPlaylists() (*SubsonicResponse, error) { query := defaultQuery(connection) requestUrl := connection.Host + "/rest/getPlaylists" + "?" + query.Encode() diff --git a/gui.go b/gui.go index 2dd9704..73c25e2 100644 --- a/gui.go +++ b/gui.go @@ -29,6 +29,7 @@ type Ui struct { currentDirectory *SubsonicDirectory artistList *tview.List artistIdList []string + starIdList map[string]struct{} playlists []SubsonicPlaylist connection *SubsonicConnection player *Player @@ -50,15 +51,16 @@ func (ui *Ui) handleEntitySelected(directoryId string) { for _, entity := range response.Directory.Entities { var title string + var id = entity.Id var handler func() if entity.IsDirectory { title = tview.Escape("[" + entity.Title + "]") handler = ui.makeEntityHandler(entity.Id) } else { - title = entity.getSongTitle() - handler = makeSongHandler(ui.connection.GetPlayUrl(&entity), + title = entityListTextFormat(entity, ui.starIdList ) + handler = makeSongHandler(id, ui.connection.GetPlayUrl(&entity), title, stringOr(entity.Artist, response.Directory.Name), - entity.Duration, ui.player, ui.queueList) + entity.Duration, ui.player, ui.queueList, ui.starIdList) } ui.entityList.AddItem(title, "", 0, handler) @@ -72,8 +74,10 @@ func (ui *Ui) handlePlaylistSelected(playlist SubsonicPlaylist) { var title string var handler func() + var id = entity.Id + title = entity.getSongTitle() - handler = makeSongHandler(ui.connection.GetPlayUrl(&entity), title, entity.Artist, entity.Duration, ui.player, ui.queueList) + handler = makeSongHandler(id, ui.connection.GetPlayUrl(&entity), title, entity.Artist, entity.Duration, ui.player, ui.queueList, ui.starIdList) ui.selectedPlaylist.AddItem(title, "", 0, handler) } @@ -106,7 +110,43 @@ func (ui *Ui) handleDeleteFromQueue() { ui.player.Queue = make([]QueueItem, 0) } - updateQueueList(ui.player, ui.queueList) + updateQueueList(ui.player, ui.queueList, ui.starIdList) +} + +func (ui *Ui) handleAddRandomSongs() { + ui.addRandomSongsToQueue() + updateQueueList(ui.player, ui.queueList, ui.starIdList) +} + +func (ui *Ui) handleToggleStar() { + currentIndex := ui.queueList.GetCurrentItem() + queue := ui.player.Queue + + if currentIndex == -1 || len(ui.player.Queue) < currentIndex { + return + } + + var entity = queue[currentIndex] + + // If the song is already in the star list, remove it + _, remove := ui.starIdList[entity.Id] + + // resp, _ := ui.connection.ToggleStar(entity.Id, remove) + ui.connection.ToggleStar(entity.Id, ui.starIdList) + + if (remove) { + delete(ui.starIdList, entity.Id) + } else { + ui.starIdList[entity.Id] = struct{}{} + } + + var text = queueListTextFormat(ui.player.Queue[currentIndex], ui.starIdList ) + updateQueueListItem(ui.queueList, currentIndex, text) + // Update the entity list to reflect any changes + ui.connection.Logger.Printf("entity test", ui.currentDirectory) + if (ui.currentDirectory != nil) { + ui.handleEntitySelected(ui.currentDirectory.Id) + } } func (ui *Ui) handleAddEntityToQueue() { @@ -133,7 +173,42 @@ func (ui *Ui) handleAddEntityToQueue() { ui.addSongToQueue(&entity) } - updateQueueList(ui.player, ui.queueList) + updateQueueList(ui.player, ui.queueList, ui.starIdList) +} + +func (ui *Ui) handleToggleEntityStar() { + currentIndex := ui.entityList.GetCurrentItem() + + var entity = ui.currentDirectory.Entities[currentIndex-1] + + // If the song is already in the star list, remove it + _, remove := ui.starIdList[entity.Id] + + ui.connection.ToggleStar(entity.Id, ui.starIdList) + + if (remove) { + delete(ui.starIdList, entity.Id) + } else { + ui.starIdList[entity.Id] = struct{}{} + } + + var text = entityListTextFormat(entity, ui.starIdList ) + updateEntityListItem(ui.entityList, currentIndex, text) + updateQueueList(ui.player, ui.queueList, ui.starIdList) +} + +func entityListTextFormat(queueItem SubsonicEntity, starredItems map[string]struct{} ) string { + var star = "" + _, hasStar := starredItems[queueItem.Id] + if hasStar { + star = " [red]♥" + } + return queueItem.Title + star +} + +// Just update the text of a specific row +func updateEntityListItem(entityList *tview.List, id int, text string) { + entityList.SetItemText(id, text, "") } func (ui *Ui) handleAddPlaylistSongToQueue() { @@ -151,7 +226,7 @@ func (ui *Ui) handleAddPlaylistSongToQueue() { entity := ui.playlists[playlistIndex].Entries[entityIndex] ui.addSongToQueue(&entity) - updateQueueList(ui.player, ui.queueList) + updateQueueList(ui.player, ui.queueList, ui.starIdList) } func (ui *Ui) handleAddPlaylistToQueue() { @@ -166,7 +241,7 @@ func (ui *Ui) handleAddPlaylistToQueue() { ui.addSongToQueue(&entity) } - updateQueueList(ui.player, ui.queueList) + updateQueueList(ui.player, ui.queueList, ui.starIdList) } func (ui *Ui) handleAddSongToPlaylist(playlist *SubsonicPlaylist) { @@ -207,6 +282,28 @@ func (ui *Ui) handleAddSongToPlaylist(playlist *SubsonicPlaylist) { } } +func (ui *Ui) addRandomSongsToQueue() { + response, err := ui.connection.GetRandomSongs() + if (err != nil) { + ui.connection.Logger.Printf("addRandomSongsToQueue", err.Error()) + } + for _, e := range response.RandomSongs.Song { + ui.addSongToQueue(&e) + } +} + +func (ui *Ui) addStarredToList() { + response, err := ui.connection.GetStarred() + if (err != nil) { + ui.connection.Logger.Printf("addStarredToList", err.Error()) + } + for _, e := range response.Starred.Song { + // We're storing empty struct as values as we only want the indexes + // It's faster having direct index access instead of looping through array values + ui.starIdList[e.Id] = struct{}{} + } +} + func (ui *Ui) addDirectoryToQueue(entity *SubsonicEntity) { response, err := ui.connection.GetMusicDirectory(entity.Id) if err != nil { @@ -275,7 +372,11 @@ func (ui *Ui) addSongToQueue(entity *SubsonicEntity) { stringOr(entity.Artist, ui.currentDirectory.Name) } + var id = entity.Id + + queueItem := QueueItem{ + id, uri, entity.getSongTitle(), artist, @@ -316,10 +417,10 @@ func (ui *Ui) deletePlaylist(index int) { ui.connection.DeletePlaylist(string(playlist.Id)) } -func makeSongHandler(uri string, title string, artist string, duration int, player *Player, queueList *tview.List) func() { +func makeSongHandler(id string, uri string, title string, artist string, duration int, player *Player, queueList *tview.List, starIdList map[string]struct{}) func() { return func() { - player.Play(uri, title, artist, duration) - updateQueueList(player, queueList) + player.Play(id, uri, title, artist, duration) + updateQueueList(player, queueList, starIdList) } } @@ -361,6 +462,8 @@ func createUi(_ *[]SubsonicIndex, playlists *[]SubsonicPlaylist, connection *Sub logs := tview.NewList().ShowSecondaryText(false) var currentDirectory *SubsonicDirectory var artistIdList []string + // Stores the song IDs + var starIdList = map[string]struct{}{} ui := Ui{ app: app, @@ -377,11 +480,14 @@ func createUi(_ *[]SubsonicIndex, playlists *[]SubsonicPlaylist, connection *Sub logList: logs, currentDirectory: currentDirectory, artistIdList: artistIdList, + starIdList: starIdList, playlists: *playlists, connection: connection, player: player, } + ui.addStarredToList() + go func() { for { select { @@ -512,6 +618,10 @@ func (ui *Ui) createBrowserPage(titleFlex *tview.Flex, indexes *[]SubsonicIndex) ui.handleAddEntityToQueue() return nil } + if event.Rune() == 'y' { + ui.handleToggleEntityStar() + return nil + } // only makes sense to add to a playlist if there are playlists if event.Rune() == 'A' && ui.playlistList.GetItemCount() > 0 { ui.pages.ShowPage("addToPlaylist") @@ -537,11 +647,13 @@ func (ui *Ui) createQueuePage(titleFlex *tview.Flex) *tview.Flex { queueFlex := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(titleFlex, 1, 0, false). AddItem(ui.queueList, 0, 1, true) - ui.queueList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyDelete || event.Rune() == 'd' { ui.handleDeleteFromQueue() return nil + } else if event.Rune() == 'y' { + ui.handleToggleStar() + return nil } return event @@ -702,13 +814,15 @@ func InitGui(indexes *[]SubsonicIndex, playlists *[]SubsonicPlaylist, connection ui.player.EventChannel <- nil ui.player.Instance.TerminateDestroy() ui.app.Stop() + case 's': + ui.handleAddRandomSongs() case 'D': ui.player.Queue = make([]QueueItem, 0) err := ui.player.Stop() if err != nil { ui.connection.Logger.Printf("InitGui: Stop -- %s", err.Error()) } - updateQueueList(ui.player, ui.queueList) + updateQueueList(ui.player, ui.queueList, ui.starIdList) case 'p': status, err := ui.player.Pause() if err != nil { @@ -758,11 +872,26 @@ func InitGui(indexes *[]SubsonicIndex, playlists *[]SubsonicPlaylist, connection return ui } -func updateQueueList(player *Player, queueList *tview.List) { + +func queueListTextFormat(queueItem QueueItem, starredItems map[string]struct{} ) string { + min, sec := iSecondsToMinAndSec(queueItem.Duration) + var star = "" + _, hasStar := starredItems[queueItem.Id] + if hasStar { + star = " [red]♥" + } + return fmt.Sprintf("%s - %s - %02d:%02d %s", queueItem.Title, queueItem.Artist, min, sec,star) +} + +// Just update the text of a specific row +func updateQueueListItem(queueList *tview.List, id int, text string) { + queueList.SetItemText(id, text, "") +} + +func updateQueueList(player *Player, queueList *tview.List, starredItems map[string]struct{}) { queueList.Clear() for _, queueItem := range player.Queue { - min, sec := iSecondsToMinAndSec(queueItem.Duration) - queueList.AddItem(fmt.Sprintf("%s - %s - %02d:%02d", queueItem.Title, queueItem.Artist, min, sec), "", 0, nil) + queueList.AddItem(queueListTextFormat(queueItem, starredItems), "", 0, nil) } } @@ -781,7 +910,7 @@ func (ui *Ui) handleMpvEvents() { if len(ui.player.Queue) > 0 { ui.player.Queue = ui.player.Queue[1:] } - updateQueueList(ui.player, ui.queueList) + updateQueueList(ui.player, ui.queueList, ui.starIdList) err := ui.player.PlayNextTrack() if err != nil { ui.connection.Logger.Printf("handleMoveEvents: PlayNextTrack -- %s", err.Error()) @@ -789,7 +918,7 @@ func (ui *Ui) handleMpvEvents() { } else if e.Event_Id == mpv.EVENT_START_FILE { ui.player.ReplaceInProgress = false ui.startStopStatus.SetText("[::b]stmp: [green]playing " + ui.player.Queue[0].Title) - updateQueueList(ui.player, ui.queueList) + updateQueueList(ui.player, ui.queueList, ui.starIdList) } else if e.Event_Id == mpv.EVENT_IDLE || e.Event_Id == mpv.EVENT_NONE { continue } diff --git a/player.go b/player.go index be3d0ac..a6a7c34 100644 --- a/player.go +++ b/player.go @@ -13,6 +13,7 @@ const ( ) type QueueItem struct { + Id string Uri string Title string Artist string @@ -60,8 +61,8 @@ func (p *Player) PlayNextTrack() error { return nil } -func (p *Player) Play(uri string, title string, artist string, duration int) error { - p.Queue = []QueueItem{{uri, title, artist, duration}} +func (p *Player) Play(id string, uri string, title string, artist string, duration int) error { + p.Queue = []QueueItem{{id, uri, title, artist, duration}} p.ReplaceInProgress = true if ip, e := p.IsPaused(); ip && e == nil { p.Pause()