Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement support for .pls and .m3u -playlists #180

Merged
merged 3 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion application/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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://") {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
31 changes: 31 additions & 0 deletions application/application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
"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"
Expand Down Expand Up @@ -42,3 +44,32 @@
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)

Check failure on line 55 in application/application_test.go

View workflow job for this annotation

GitHub Actions / test (1.21.x, ubuntu-latest)

undefined: application.NewPlaylistIterator

Check failure on line 55 in application/application_test.go

View workflow job for this annotation

GitHub Actions / test (1.21.x, macos-latest)

undefined: application.NewPlaylistIterator
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)
}
}
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
83 changes: 83 additions & 0 deletions playlists/playlist_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
122 changes: 122 additions & 0 deletions playlists/playlists.go
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions playlists/testdata/indiepop130.m3u
Original file line number Diff line number Diff line change
@@ -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


19 changes: 19 additions & 0 deletions playlists/testdata/indiepop64.pls
Original file line number Diff line number Diff line change
@@ -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

Loading
Loading