diff --git a/application/application.go b/application/application.go index 177da77..d48f92f 100644 --- a/application/application.go +++ b/application/application.go @@ -23,7 +23,10 @@ import ( "github.com/vishen/go-chromecast/cast" pb "github.com/vishen/go-chromecast/cast/proto" + "github.com/vishen/go-chromecast/playlists" "github.com/vishen/go-chromecast/storage" + "gopkg.in/ini.v1" + "path/filepath" ) var ( @@ -764,6 +767,35 @@ func (a *Application) PlayedItems() map[string]PlayedItem { } func (a *Application) Load(filenameOrUrl string, startTime int, contentType string, transcode, detach, forceDetach bool) error { + // if the file is a playlist, ".pls", then just play the first item. + if playlists.IsPlaylist(filenameOrUrl) { + if strings.HasPrefix(filenameOrUrl, "./") { // convert to file:// uri + if abs, err := filepath.Abs(filenameOrUrl); err != nil { + return err + } else { + filenameOrUrl = fmt.Sprintf("file://%v", abs) + } + } + it, err := playlists.NewIterator(filenameOrUrl) + if err != nil { + return err + } + var items []mediaItem + for it.HasNext() { + url, title := it.Next() + items = append(items, mediaItem{ + filename: url, + contentURL: url, + }) + fmt.Printf("Adding url %v (%v)\n", url, title) + } + return a.QueueLoadItems(items, "") + } + return a.play(filenameOrUrl, startTime, contentType, transcode, detach, forceDetach) +} + +func (a *Application) play(filenameOrUrl string, startTime int, contentType string, transcode, detach, forceDetach bool) error { + var mi mediaItem isExternalMedia := false if strings.HasPrefix(filenameOrUrl, "http://") || strings.HasPrefix(filenameOrUrl, "https://") { @@ -850,11 +882,14 @@ func (a *Application) LoadApp(appID, contentID string) error { } func (a *Application) QueueLoad(filenames []string, contentType string, transcode bool) error { - mediaItems, err := a.loadAndServeFiles(filenames, contentType, transcode) if err != nil { return errors.Wrap(err, "unable to load and serve files") } + return a.QueueLoadItems(mediaItems, contentType) +} + +func (a *Application) QueueLoadItems(mediaItems []mediaItem, contentType string) error { if err := a.ensureIsDefaultMediaReceiver(); err != nil { return err @@ -1392,3 +1427,12 @@ func (a *Application) Transcode(contentType string, command string, args ...stri a.MediaWait() return nil } + +// plsIterator is an iterator for playlist-files. +// According to https://en.wikipedia.org/wiki/PLS_(file_format), +// The format is case-sensitive and essentially that of an INI file. +// It has entries on the form File1, Title1 etc. +type plsIterator struct { + count int + playlist *ini.Section +} diff --git a/application/application_test.go b/application/application_test.go index 6d5b8f8..8632cf2 100644 --- a/application/application_test.go +++ b/application/application_test.go @@ -4,12 +4,14 @@ import ( "encoding/json" "testing" + "fmt" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/vishen/go-chromecast/application" "github.com/vishen/go-chromecast/cast" mockCast "github.com/vishen/go-chromecast/cast/mocks" pb "github.com/vishen/go-chromecast/cast/proto" + "path/filepath" ) var mockAddr = "foo.bar" @@ -42,3 +44,32 @@ func TestApplicationStart(t *testing.T) { app := application.NewApplication(application.WithConnection(conn)) assertions.NoError(app.Start(mockAddr, mockPort)) } + +func TestParsePlaylist(t *testing.T) { + var path string + if abs, err := filepath.Abs(filepath.Join("..", "testdata", "indiepop64.pls")); err != nil { + t.Fatal(err) + } else { + path = fmt.Sprintf("file://%v", abs) + } + it, err := application.NewPlaylistIterator(path) + if err != nil { + t.Fatal(err) + } + var wantUrls = []string{ + "https://ice4.somafm.com/indiepop-64-aac", + "https://ice2.somafm.com/indiepop-64-aac", + "https://ice1.somafm.com/indiepop-64-aac", + "https://ice6.somafm.com/indiepop-64-aac", + "https://ice5.somafm.com/indiepop-64-aac", + } + for i, want := range wantUrls { + if !it.HasNext() { + t.Fatal("iterator exhausted") + } + have, _ := it.Next() + if have != want { + t.Fatalf("url %d, have %v want %v", i, have, want) + } + } +} diff --git a/go.mod b/go.mod index d9d7948..7316b4c 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,8 @@ require ( google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 ) +require gopkg.in/ini.v1 v1.67.0 + require ( cloud.google.com/go v0.110.0 // indirect cloud.google.com/go/compute v1.19.1 // indirect diff --git a/go.sum b/go.sum index f550cd3..ebb75e6 100644 --- a/go.sum +++ b/go.sum @@ -228,6 +228,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/playlists/playlist_test.go b/playlists/playlist_test.go new file mode 100644 index 0000000..67ae20d --- /dev/null +++ b/playlists/playlist_test.go @@ -0,0 +1,83 @@ +package playlists + +import ( + "fmt" + "path/filepath" + "testing" +) + +func TestParsePLSFormat(t *testing.T) { + var wantUrls = []struct { + url string + title string + }{ + {"https://ice4.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#1): New and classic favorite indie pop tracks."}, + {"https://ice2.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#2): New and classic favorite indie pop tracks."}, + {"https://ice1.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#3): New and classic favorite indie pop tracks."}, + {"https://ice6.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#4): New and classic favorite indie pop tracks."}, + {"https://ice5.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#5): New and classic favorite indie pop tracks."}, + } + var path string + if abs, err := filepath.Abs(filepath.Join("testdata", "indiepop64.pls")); err != nil { + t.Fatal(err) + } else { + path = fmt.Sprintf("file://%v", abs) + } + it, err := newPLSIterator(path) + if err != nil { + t.Fatal(err) + } + for i, want := range wantUrls { + if !it.HasNext() { + t.Fatal("iterator exhausted") + } + url, title := it.Next() + if url != want.url { + t.Fatalf("test %d, have url %v want %v", i, url, want.url) + } + if title != want.title { + t.Fatalf("test %d, have title %v want %v", i, title, want.title) + } + } + if it.HasNext() { + t.Fatal("expected exhausted iterator") + } +} + +func TestParseM3UFormat(t *testing.T) { + var wantUrls = []struct { + url string + title string + }{ + {"http://ice1.somafm.com/indiepop-128-aac", ""}, + {"http://ice4.somafm.com/indiepop-128-aac", ""}, + {"http://ice2.somafm.com/indiepop-128-aac", ""}, + {"http://ice6.somafm.com/indiepop-128-aac", ""}, + {"http://ice5.somafm.com/indiepop-128-aac", ""}, + } + var path string + if abs, err := filepath.Abs(filepath.Join("testdata", "indiepop130.m3u")); err != nil { + t.Fatal(err) + } else { + path = fmt.Sprintf("file://%v", abs) + } + it, err := newM3UIterator(path) + if err != nil { + t.Fatal(err) + } + for i, want := range wantUrls { + if !it.HasNext() { + t.Fatal("iterator exhausted") + } + url, title := it.Next() + if url != want.url { + t.Fatalf("test %d, have url %v want %v", i, url, want.url) + } + if title != want.title { + t.Fatalf("test %d, have title %v want %v", i, title, want.title) + } + } + if it.HasNext() { + t.Fatal("expected exhausted iterator") + } +} diff --git a/playlists/playlists.go b/playlists/playlists.go new file mode 100644 index 0000000..4879b49 --- /dev/null +++ b/playlists/playlists.go @@ -0,0 +1,122 @@ +package playlists + +import ( + "fmt" + "gopkg.in/ini.v1" + "path/filepath" + "strings" +) + +type Iterator interface { + HasNext() bool + Next() (file, title string) +} + +func IsPlaylist(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".m3u" || ext == ".pls" +} + +// NewPlaylistIterator creates an iterator for the given playlist. +func NewIterator(uri string) (Iterator, error) { + ext := strings.ToLower(filepath.Ext(uri)) + switch ext { + case ".pls": + return newPLSIterator(uri) + case ".m3u": + return newM3UIterator(uri) + } + return nil, fmt.Errorf("'%v' is not a recognized playlist format", ext) +} + +// plsIterator is an iterator for playlist-files. +// According to https://en.wikipedia.org/wiki/PLS_(file_format), +// The format is case-sensitive and essentially that of an INI file. +// It has entries on the form File1, Title1 etc. +type plsIterator struct { + count int + playlist *ini.Section +} + +func newPLSIterator(uri string) (*plsIterator, error) { + content, err := FetchResource(uri) + if err != nil { + return nil, fmt.Errorf("failed to read file %v: %w", uri, err) + } + pls, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, content) + if err != nil { + return nil, fmt.Errorf("failed to parse file %v: %w", uri, err) + } + section, err := pls.GetSection("playlist") + if err != nil { + return nil, fmt.Errorf("failed to find playlist in .pls-file %v", uri) + } + return &plsIterator{ + playlist: section, + }, nil +} + +func (it *plsIterator) HasNext() bool { + return it.playlist.HasKey(fmt.Sprintf("File%d", it.count+1)) +} + +func (it *plsIterator) Next() (file, title string) { + if val := it.playlist.Key(fmt.Sprintf("File%d", it.count+1)); val != nil { + file = val.Value() + } + if val := it.playlist.Key(fmt.Sprintf("Title%d", it.count+1)); val != nil { + title = val.Value() + } + it.count = it.count + 1 + return file, title +} + +// m3uIterator is an iterator for m3u-files. +// https://docs.fileformat.com/audio/m3u/: +// +// There is no official specification for the M3U file format, it is a de-facto standard. +// M3U is a plain text file that uses the .m3u extension if the text is encoded +// in the local system’s default non-Unicode encoding or with the .m3u8 extension +// if the text is UTF-8 encoded. Each entry in the M3U file can be one of the following: +// +// - Absolute path to the file +// - File path relative to the M3U file. +// - URL +// +// In the extended M3U, additional directives are introduced that begin +// with “#” and end with a colon(:) if they have parameters +type m3uIterator struct { + index int + lines []string +} + +func newM3UIterator(uri string) (*m3uIterator, error) { + content, err := FetchResource(uri) + if err != nil { + return nil, fmt.Errorf("failed to read file %v: %w", uri, err) + } + var lines []string + // convert windows linebreaks, and split + for _, l := range strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n") { + // This is a very simple m3u decoder, ignores all extended info + l = strings.TrimSpace(l) + if len(l) > 0 && !strings.HasPrefix(l, "#") { + lines = append(lines, l) + } + } + return &m3uIterator{ + index: 0, + lines: lines, + }, nil +} + +func (it *m3uIterator) HasNext() bool { + return it.index < len(it.lines) +} + +func (it *m3uIterator) Next() (file, title string) { + file = it.lines[it.index] + title = "" // Todo? + it.index++ + return file, title +} diff --git a/playlists/testdata/indiepop130.m3u b/playlists/testdata/indiepop130.m3u new file mode 100644 index 0000000..287f542 --- /dev/null +++ b/playlists/testdata/indiepop130.m3u @@ -0,0 +1,13 @@ +#EXTM3U +#EXTINF:-1,SomaFM - Indie Pop Rocks! +http://ice1.somafm.com/indiepop-128-aac +#EXTINF:-1,SomaFM - Indie Pop Rocks! +http://ice4.somafm.com/indiepop-128-aac +#EXTINF:-1,SomaFM - Indie Pop Rocks! +http://ice2.somafm.com/indiepop-128-aac +#EXTINF:-1,SomaFM - Indie Pop Rocks! +http://ice6.somafm.com/indiepop-128-aac +#EXTINF:-1,SomaFM - Indie Pop Rocks! +http://ice5.somafm.com/indiepop-128-aac + + diff --git a/playlists/testdata/indiepop64.pls b/playlists/testdata/indiepop64.pls new file mode 100644 index 0000000..4566434 --- /dev/null +++ b/playlists/testdata/indiepop64.pls @@ -0,0 +1,19 @@ +[playlist] +numberofentries=5 +File1=https://ice4.somafm.com/indiepop-64-aac +Title1=SomaFM: Indie Pop Rocks! (#1): New and classic favorite indie pop tracks. +Length1=-1 +File2=https://ice2.somafm.com/indiepop-64-aac +Title2=SomaFM: Indie Pop Rocks! (#2): New and classic favorite indie pop tracks. +Length2=-1 +File3=https://ice1.somafm.com/indiepop-64-aac +Title3=SomaFM: Indie Pop Rocks! (#3): New and classic favorite indie pop tracks. +Length3=-1 +File4=https://ice6.somafm.com/indiepop-64-aac +Title4=SomaFM: Indie Pop Rocks! (#4): New and classic favorite indie pop tracks. +Length4=-1 +File5=https://ice5.somafm.com/indiepop-64-aac +Title5=SomaFM: Indie Pop Rocks! (#5): New and classic favorite indie pop tracks. +Length5=-1 +Version=2 + diff --git a/playlists/utlis.go b/playlists/utlis.go new file mode 100644 index 0000000..c375b76 --- /dev/null +++ b/playlists/utlis.go @@ -0,0 +1,26 @@ +package playlists + +import ( + "io" + "net/http" + "os" + "strings" +) + +// FetchResource fetches the given url and returns the response body. The url can either +// be an HTTP url or a file:// url. +func FetchResource(url string) ([]byte, error) { + if filep := strings.TrimPrefix(url, "file://"); filep != url { + return os.ReadFile(filep) + } + res, err := http.Get(url) + if err != nil { + return nil, err + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + return body, nil +} diff --git a/testdata/indiepop64.pls b/testdata/indiepop64.pls new file mode 100644 index 0000000..4566434 --- /dev/null +++ b/testdata/indiepop64.pls @@ -0,0 +1,19 @@ +[playlist] +numberofentries=5 +File1=https://ice4.somafm.com/indiepop-64-aac +Title1=SomaFM: Indie Pop Rocks! (#1): New and classic favorite indie pop tracks. +Length1=-1 +File2=https://ice2.somafm.com/indiepop-64-aac +Title2=SomaFM: Indie Pop Rocks! (#2): New and classic favorite indie pop tracks. +Length2=-1 +File3=https://ice1.somafm.com/indiepop-64-aac +Title3=SomaFM: Indie Pop Rocks! (#3): New and classic favorite indie pop tracks. +Length3=-1 +File4=https://ice6.somafm.com/indiepop-64-aac +Title4=SomaFM: Indie Pop Rocks! (#4): New and classic favorite indie pop tracks. +Length4=-1 +File5=https://ice5.somafm.com/indiepop-64-aac +Title5=SomaFM: Indie Pop Rocks! (#5): New and classic favorite indie pop tracks. +Length5=-1 +Version=2 +