diff --git a/backend/app.go b/backend/app.go index 4b4823d..274dc77 100644 --- a/backend/app.go +++ b/backend/app.go @@ -193,7 +193,10 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas ipc.DestroyConn() // cleanup socket possibly orphaned by crashed process listener, err := ipc.Listen() if err == nil { - a.ipcServer = ipc.NewServer(a.PlaybackManager, a.callOnReactivate, + a.ipcServer = ipc.NewServer( + a.PlaybackManager, + a.ServerManager, + a.callOnReactivate, func() { _ = a.callOnExit() }) go a.ipcServer.Serve(listener) } else { @@ -582,6 +585,30 @@ func (a *App) checkFlagsAndSendIPCMsg(cli *ipc.Client) error { return cli.SeekSeconds(SeekToCLIArg) case SeekByCLIArg != 0: return cli.SeekBySeconds(SeekByCLIArg) + case PlayAlbumCLIArg != "": + return cli.PlayAlbum(PlayAlbumCLIArg, FirstTrackCLIArg, *FlagShuffle) + case PlayPlaylistCLIArg != "": + return cli.PlayPlaylist(PlayPlaylistCLIArg, FirstTrackCLIArg, *FlagShuffle) + case PlayTrackCLIArg != "": + return cli.PlayTrack(PlayTrackCLIArg) + case SearchAlbumCLIArg != "": + data, err := cli.SearchAlbum(SearchAlbumCLIArg) + if err == nil { + fmt.Println(data) + } + return err + case SearchPlaylistCLIArg != "": + data, err := cli.SearchPlaylist(SearchPlaylistCLIArg) + if err == nil { + fmt.Println(data) + } + return err + case SearchTrackCLIArg != "": + data, err := cli.SearchTrack(SearchTrackCLIArg) + if err == nil { + fmt.Println(data) + } + return err default: return nil } diff --git a/backend/cmdlineoptions.go b/backend/cmdlineoptions.go index 4750bf2..22e792c 100644 --- a/backend/cmdlineoptions.go +++ b/backend/cmdlineoptions.go @@ -2,15 +2,25 @@ package backend import ( "flag" + "os" "strconv" "strings" + + "golang.org/x/term" ) var ( - VolumeCLIArg int = -1 - SeekToCLIArg float64 = -1 - SeekByCLIArg float64 = 0 - VolumePctCLIArg float64 = 0 + VolumeCLIArg int = -1 + SeekToCLIArg float64 = -1 + SeekByCLIArg float64 = 0 + VolumePctCLIArg float64 = 0 + PlayAlbumCLIArg string = "" + PlayPlaylistCLIArg string = "" + PlayTrackCLIArg string = "" + FirstTrackCLIArg int = 0 + SearchAlbumCLIArg string = "" + SearchPlaylistCLIArg string = "" + SearchTrackCLIArg string = "" FlagPlay = flag.Bool("play", false, "unpause or begin playback") FlagPause = flag.Bool("pause", false, "pause playback") @@ -21,8 +31,13 @@ var ( FlagStopAfterCurrent = flag.Bool("stop-after-current", false, "stop playback after current track") FlagStartMinimized = flag.Bool("start-minimized", false, "start app minimized") FlagShow = flag.Bool("show", false, "show minimized app") + FlagShuffle = flag.Bool("shuffle", false, "shuffle the tracklist (to be used with either -play-album-by-id or -play-playlist-by-id)") FlagVersion = flag.Bool("version", false, "print app version and exit") FlagHelp = flag.Bool("help", false, "print command line options and exit") + + FlagPlayAlbum *bool + FlagPlayPlaylist *bool + FlagPlayTrack *bool ) func init() { @@ -50,6 +65,43 @@ func init() { VolumePctCLIArg = v return err }) + + if term.IsTerminal(int(os.Stdin.Fd())) { + flag.Func("play-album-by-id", "start playing the album with the given ID (can also be passed from standard input)", func(s string) error { + PlayAlbumCLIArg = s + return nil + }) + flag.Func("play-playlist-by-id", "start playing the playlist with the given ID (can also be passed from standard input)", func(s string) error { + PlayPlaylistCLIArg = s + return nil + }) + flag.Func("play-track-by-id", "start playing the track with the given ID (can also be passed from standard input)", func(s string) error { + PlayTrackCLIArg = s + return nil + }) + } else { + FlagPlayAlbum = flag.Bool("play-album-by-id", false, "") + FlagPlayPlaylist = flag.Bool("play-playlist-by-id", false, "") + FlagPlayTrack = flag.Bool("play-track-by-id", false, "") + } + flag.Func("first-track", "start playing from given track (positive integer, to be used with either -play-album or -play-playlist)", func(s string) error { + v, err := strconv.Atoi(s) + FirstTrackCLIArg = v + return err + }) + + flag.Func("search-album", "search album by name, return results as JSON", func(s string) error { + SearchAlbumCLIArg = s + return nil + }) + flag.Func("search-playlist", "search playlist by name, return results as JSON", func(s string) error { + SearchPlaylistCLIArg = s + return nil + }) + flag.Func("search-track", "search track by name, return results as JSON", func(s string) error { + SearchTrackCLIArg = s + return nil + }) } func HaveCommandLineOptions() bool { diff --git a/backend/ipc/api.go b/backend/ipc/api.go index f163962..db4d636 100644 --- a/backend/ipc/api.go +++ b/backend/ipc/api.go @@ -1,10 +1,20 @@ package ipc -import "fmt" +import ( + "encoding/json" + "fmt" + "net/url" +) const ( PingPath = "/ping" PlayPath = "/transport/play" + PlayAlbumPath = "/transport/play-album" // ?id=&t=&s= + PlayPlaylistPath = "/transport/play-playlist" // ?id=&t=&s= + PlayTrackPath = "/transport/play-track" // ?id= + SearchAlbumPath = "/transport/search-album" // ?s= + SearchPlaylistPath = "/transport/search-playlist" // ?s= + SearchTrackPath = "/transport/search-track" // ?s= PlayPausePath = "/transport/playpause" PausePath = "/transport/pause" StopPath = "/transport/stop" @@ -20,7 +30,8 @@ const ( ) type Response struct { - Error string `json:"error"` + Data json.RawMessage `json:"data"` + Error string `json:"error"` } func SetVolumePath(vol int) string { @@ -38,3 +49,30 @@ func SeekToSecondsPath(secs float64) string { func SeekBySecondsPath(secs float64) string { return fmt.Sprintf("%s?s=%0.2f", SeekByPath, secs) } + +func BuildPlayAlbumPath(id string, firstTrack int, shuffle bool) string { + return fmt.Sprintf("%s?id=%s&t=%d&s=%t", PlayAlbumPath, id, firstTrack, shuffle) +} + +func BuildPlayPlaylistPath(id string, firstTrack int, shuffle bool) string { + return fmt.Sprintf("%s?id=%s&t=%d&s=%t", PlayPlaylistPath, id, firstTrack, shuffle) +} + +func BuildPlayTrackPath(id string) string { + return fmt.Sprintf("%s?id=%s", PlayTrackPath, id) +} + +func BuildSearchAlbumPath(search string) string { + s := url.QueryEscape(search) + return fmt.Sprintf("%s?s=%s", SearchAlbumPath, s) +} + +func BuildSearchPlaylistPath(search string) string { + s := url.QueryEscape(search) + return fmt.Sprintf("%s?s=%s", SearchPlaylistPath, s) +} + +func BuildSearchTrackPath(search string) string { + s := url.QueryEscape(search) + return fmt.Sprintf("%s?s=%s", SearchTrackPath, s) +} diff --git a/backend/ipc/client.go b/backend/ipc/client.go index 7e9bc80..3b91ad6 100644 --- a/backend/ipc/client.go +++ b/backend/ipc/client.go @@ -30,75 +30,115 @@ func Connect() (*Client, error) { } func (c *Client) Ping() error { - if c.sendRequest(PingPath) != nil { + if _, err := c.sendRequest(PingPath); err != nil { return ErrPingFail } return nil } func (c *Client) Play() error { - return c.sendRequest(PlayPath) + _, err := c.sendRequest(PlayPath) + return err } func (c *Client) Pause() error { - return c.sendRequest(PausePath) + _, err := c.sendRequest(PausePath) + return err +} + +func (c *Client) PlayAlbum(id string, firstTrack int, shuffle bool) error { + _, err := c.sendRequest(BuildPlayAlbumPath(id, firstTrack, shuffle)) + return err +} + +func (c *Client) PlayPlaylist(id string, firstTrack int, shuffle bool) error { + _, err := c.sendRequest(BuildPlayPlaylistPath(id, firstTrack, shuffle)) + return err +} + +func (c *Client) SearchAlbum(search string) (string, error) { + return c.sendRequest(BuildSearchAlbumPath(search)) +} + +func (c *Client) SearchPlaylist(search string) (string, error) { + return c.sendRequest(BuildSearchPlaylistPath(search)) +} + +func (c *Client) SearchTrack(search string) (string, error) { + return c.sendRequest(BuildSearchTrackPath(search)) +} + +func (c *Client) PlayTrack(id string) error { + _, err := c.sendRequest(BuildPlayTrackPath(id)) + return err } func (c *Client) PlayPause() error { - return c.sendRequest(PlayPausePath) + _, err := c.sendRequest(PlayPausePath) + return err } func (c *Client) Stop() error { - return c.sendRequest(StopPath) + _, err := c.sendRequest(StopPath) + return err } func (c *Client) StopAfterCurrent() error { - return c.sendRequest(StopAfterCurrentPath) + _, err := c.sendRequest(StopAfterCurrentPath) + return err } func (c *Client) SeekNext() error { - return c.sendRequest(NextPath) + _, err := c.sendRequest(NextPath) + return err } func (c *Client) SeekBackOrPrevious() error { - return c.sendRequest(PreviousPath) + _, err := c.sendRequest(PreviousPath) + return err } func (c *Client) SeekSeconds(secs float64) error { - return c.sendRequest(SeekToSecondsPath(secs)) + _, err := c.sendRequest(SeekToSecondsPath(secs)) + return err } func (c *Client) SeekBySeconds(secs float64) error { - return c.sendRequest(SeekBySecondsPath(secs)) + _, err := c.sendRequest(SeekBySecondsPath(secs)) + return err } func (c *Client) SetVolume(vol int) error { - return c.sendRequest(SetVolumePath(vol)) + _, err := c.sendRequest(SetVolumePath(vol)) + return err } func (c *Client) AdjustVolumePct(pct float64) error { - return c.sendRequest(AdjustVolumePctPath(pct)) + _, err := c.sendRequest(AdjustVolumePctPath(pct)) + return err } func (c *Client) Show() error { - return c.sendRequest(ShowPath) + _, err := c.sendRequest(ShowPath) + return err } func (c *Client) Quit() error { - return c.sendRequest(QuitPath) + _, err := c.sendRequest(QuitPath) + return err } -func (c *Client) sendRequest(path string) error { +func (c *Client) sendRequest(path string) (string, error) { resp, err := c.httpC.Get("http://supersonic/" + path) if err != nil { - return err + return "", err } defer resp.Body.Close() + var r Response + json.NewDecoder(resp.Body).Decode(&r) if resp.StatusCode != http.StatusOK { - var r Response - json.NewDecoder(resp.Body).Decode(&r) - return errors.New(r.Error) + return "", errors.New(r.Error) } - return nil + return string(r.Data), nil } diff --git a/backend/ipc/server.go b/backend/ipc/server.go index 7c91408..d5dc662 100644 --- a/backend/ipc/server.go +++ b/backend/ipc/server.go @@ -3,11 +3,17 @@ package ipc import ( "context" "encoding/json" + "errors" "net" "net/http" "strconv" + "strings" + + "github.com/dweymouth/supersonic/backend/mediaprovider" ) +var ErrNoServerConnection = errors.New("not connected to a server") + type PlaybackHandler interface { PlayPause() Stop() @@ -20,6 +26,9 @@ type PlaybackHandler interface { SeekBySeconds(float64) Volume() int SetVolume(int) + PlayAlbum(string, int, bool) error + PlayPlaylist(string, int, bool) error + PlayTrack(string) error } type IPCServer interface { @@ -27,15 +36,20 @@ type IPCServer interface { Shutdown(context.Context) error } +type ServerManager interface { + GetServer() mediaprovider.MediaProvider +} + type serverImpl struct { server *http.Server pbHandler PlaybackHandler + sm ServerManager showFn func() quitFn func() } -func NewServer(pbHandler PlaybackHandler, showFn, quitFn func()) IPCServer { - s := &serverImpl{pbHandler: pbHandler, showFn: showFn, quitFn: quitFn} +func NewServer(pbHandler PlaybackHandler, sm ServerManager, showFn, quitFn func()) IPCServer { + s := &serverImpl{pbHandler: pbHandler, sm: sm, showFn: showFn, quitFn: quitFn} s.server = &http.Server{ Handler: s.createHandler(), } @@ -90,6 +104,74 @@ func (s *serverImpl) createHandler() http.Handler { vol = vol + int(float64(vol)*(pct/100)) s.pbHandler.SetVolume(vol) // will clamp to range for us })) + m.HandleFunc(PlayAlbumPath, s.makeTracklistEndpointHandler(s.pbHandler.PlayAlbum)) + m.HandleFunc(PlayPlaylistPath, s.makeTracklistEndpointHandler(s.pbHandler.PlayPlaylist)) + m.HandleFunc(PlayTrackPath, func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + s.pbHandler.PlayTrack(id) + s.writeOK(w) + }) + m.HandleFunc(SearchAlbumPath, s.makeSearchEndpointHandler(func(search string) (any, error) { + mp := s.sm.GetServer() + if mp == nil { + return nil, ErrNoServerConnection + } + + filter := mediaprovider.NewAlbumFilter(mediaprovider.AlbumFilterOptions{}) + i := mp.SearchAlbums(search, filter) + + album := i.Next() + albums := make([]mediaprovider.Album, 0) + for album != nil { + albums = append(albums, *album) + album = i.Next() + } + + return albums, nil + })) + m.HandleFunc(SearchPlaylistPath, s.makeSearchEndpointHandler(func(search string) (any, error) { + mp := s.sm.GetServer() + if mp == nil { + return nil, ErrNoServerConnection + } + + all, err := mp.GetPlaylists() + if err != nil { + return nil, err + } + + search = strings.ReplaceAll(search, " ", "") + search = strings.ToLower(search) + + filtered := make([]mediaprovider.Playlist, 0) + for i := 0; i < len(all); i++ { + playlist := all[i] + name := strings.ReplaceAll(playlist.Name, " ", "") + name = strings.ToLower(name) + if strings.Contains(name, search) { + filtered = append(filtered, *playlist) + } + } + + return filtered, nil + })) + m.HandleFunc(SearchTrackPath, s.makeSearchEndpointHandler(func(search string) (any, error) { + mp := s.sm.GetServer() + if mp == nil { + return nil, ErrNoServerConnection + } + + i := mp.IterateTracks(search) + + track := i.Next() + tracks := make([]mediaprovider.Track, 0) + for track != nil { + tracks = append(tracks, *track) + track = i.Next() + } + + return tracks, nil + })) return m } @@ -112,6 +194,41 @@ func (s *serverImpl) makeFloatEndpointHandler(queryParam string, f func(float64) } } +func (s *serverImpl) makeTracklistEndpointHandler(f func(string, int, bool) error) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + id := query.Get("id") + firstTrack, err := strconv.Atoi(query.Get("t")) + if err != nil { + s.writeErr(w, err) + return + } + shuffle, err := strconv.ParseBool(query.Get("s")) + if err != nil { + s.writeErr(w, err) + return + } + f(id, firstTrack, shuffle) + s.writeOK(w) + } +} + +func (s *serverImpl) makeSearchEndpointHandler(f func(string) (any, error)) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + search := r.URL.Query().Get("s") + data, err := f(search) + if err != nil { + s.writeErr(w, err) + return + } + bytes, err := json.Marshal(data) + if err != nil { + s.writeErr(w, err) + } + s.writeData(w, bytes) + } +} + func (s *serverImpl) writeOK(w http.ResponseWriter) (int, error) { var r Response b, err := json.Marshal(&r) @@ -121,6 +238,16 @@ func (s *serverImpl) writeOK(w http.ResponseWriter) (int, error) { return w.Write(b) } +func (s *serverImpl) writeData(w http.ResponseWriter, data []byte) (int, error) { + r := Response{Data: data} + b, err := json.Marshal(&r) + if err != nil { + return 0, err + } + w.Header().Set("Content-Type", "application/json") + return w.Write(b) +} + func (s *serverImpl) writeErr(w http.ResponseWriter, err error) (int, error) { r := Response{Error: err.Error()} b, err := json.Marshal(&r) diff --git a/backend/servermanager.go b/backend/servermanager.go index 3bba6ef..4cb23de 100644 --- a/backend/servermanager.go +++ b/backend/servermanager.go @@ -262,3 +262,7 @@ func (s *ServerManager) checkSetInsecureSkipVerify(cli *http.Client) { } } } + +func (a *ServerManager) GetServer() mediaprovider.MediaProvider { + return a.Server +} diff --git a/go.mod b/go.mod index 9a13fcd..29a0db3 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/zalando/go-keyring v0.2.6 golang.org/x/net v0.35.0 golang.org/x/sys v0.30.0 + golang.org/x/term v0.29.0 golang.org/x/text v0.22.0 ) diff --git a/go.sum b/go.sum index 21a9689..21bade3 100644 --- a/go.sum +++ b/go.sum @@ -170,6 +170,8 @@ golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/main.go b/main.go index 2922325..d431273 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "github.com/dweymouth/supersonic/res/wintaskbarthumbs" "github.com/dweymouth/supersonic/ui" "github.com/dweymouth/supersonic/ui/util" + "golang.org/x/term" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -35,6 +36,15 @@ func main() { flag.Usage() return } + if !term.IsTerminal(int(os.Stdin.Fd())) { + if *backend.FlagPlayAlbum { + fmt.Scanln(&backend.PlayAlbumCLIArg) + } else if *backend.FlagPlayPlaylist { + fmt.Scanln(&backend.PlayPlaylistCLIArg) + } else if *backend.FlagPlayTrack { + fmt.Scanln(&backend.PlayTrackCLIArg) + } + } // rest of flag actions are handled in backend.StartupApp myApp, err := backend.StartupApp(res.AppName, res.DisplayName, res.AppVersion, res.AppVersionTag, res.LatestReleaseURL)