Skip to content
This repository was archived by the owner on Nov 18, 2023. It is now read-only.
Open
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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ fmt:
go fmt . ./apps ./apps/youtube ./apps/youtube/mp ./config ./log ./server

run: build
../../bin/plaincast
${GOPATH}bin/plaincast

install:
cp ../../bin/plaincast /usr/local/bin/plaincast.new
cp ${GOPATH}bin/plaincast /usr/local/bin/plaincast.new
mv /usr/local/bin/plaincast.new /usr/local/bin/plaincast
if ! egrep -q "^plaincast:" /etc/passwd; then useradd -s /bin/false -r -M plaincast -g audio; fi
mkdir -p /var/local/plaincast
Expand Down
70 changes: 58 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,66 @@ directory. In any case, set the environment variable `$GOROOT` to this path:
Then get the required packages and compile:

$ go get -u github.com/aykevl/plaincast

To run the server, you can run the executable `bin/plaincast` relative to your Go
workspace.

To run the server, run the executable `bin/plaincast` relative to your Go
workspace. Any Android phone with YouTube app (or possibly iPhone, but I haven't
tested) on the same network should recognize the server and it should be
possible to play the audio of videos on it. The Chrome extension doesn't yet
work.
$ bin/plaincast [OPTIONS]

or install it as service

$ cd src/github.com/aykevl/plaincast
$ make install

If you want to remove service `$ make remove`

Any browser that supports chromecast extension and Android phone with YouTube app
(or possibly iPhone, but I haven't tested) on the same network should recognize
the server and it should be possible to play the audio of videos on it.


### Manual service installation

Copy compiled binary file `plaincast` to `/usr/local/bin/` and create new user *plaincast* in group *audio*

$ useradd -s /bin/false -r -M plaincast -g audio

Create directory

$ mkdir -p /var/local/plaincast
$ chown plaincast:audio /var/local/plaincast

Copy systemd unit file `plaincast.service` to `/etc/systemd/system/` and enable the service

`$ systemctl enable plaincast`


## Options
-h, -help Prints help text and exit
-ao-pcm Write audio to a file, 48kHz stereo format S16
-app Name of the app to run on startup, no need to use
as currently is supported only YouTube
-cachedir Cache directory location for youtube-dl
-config Location of the configuration file, path to to config
(default location ~/.config/plaincast.json)
-friendly-name Custom friendly name (default "Plaincast HOSTNAME")
-http-port Custom http port (default 8008)
-log-libmpv Log output of libmpv
-log-mpv Log MPV wrapper output
-log-player Log media player messages
-log-server Log HTTP and SSDP server
-log-youtube Log YouTube app
-loglevel Baseline loglevel (info, warn, err) (default "warn")
-no-config Disable reading from and writing to config file
-no-sspd Disable SSDP server


### Snapcast support

You can easily write audio output to snapcast pipe using option

`-ao-pcm PATH-TO-SNAPFIFO`

$ bin/plaincast

## Notes on youtube-dl

Expand Down Expand Up @@ -80,12 +132,6 @@ It is advisable to run this regularly as it has to keep up with YouTube updates.
Certainly first try updating youtube-dl when plaincast stops working.


## Known issues

* So far, only DIAL is implemented, so the Chrome extension for Chromecast
doesn't work yet (I suspect it uses mDNS, which is the successor of DIAL on
Chromecast).

## Thanks

I would like to thank the creators of
Expand Down
1 change: 1 addition & 0 deletions apps/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ type App interface {
Running() bool
Quit()
FriendlyName() string // return a human-readable name
Data(string) string // return data from app
}
2 changes: 1 addition & 1 deletion apps/youtube/mp/mp.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/aykevl/plaincast/log"
)

var logger = log.New("player", "log media player messages")
var logger = log.New("player", "Log media player messages")

var cacheDir = flag.String("cachedir", "", "Cache directory")

Expand Down
21 changes: 18 additions & 3 deletions apps/youtube/mp/mpv.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,16 @@ type MPV struct {
mainloopExit chan struct{}
}

var mpvLogger = log.New("mpv", "log MPV wrapper output")
var logLibMPV = flag.Bool("log-libmpv", false, "log output of libmpv")
var mpvLogger = log.New("mpv", "Log MPV wrapper output")
var logLibMPV = flag.Bool("log-libmpv", false, "Log output of libmpv")
var flagPCM = flag.String("ao-pcm", "", "Write audio to a file, 48kHz stereo format S16")
var httpPort string

// New creates a new MPV instance and initializes the libmpv player
func (mpv *MPV) initialize() (chan State, int) {

httpPort = flag.Lookup("http-port").Value.String()

if mpv.handle != nil || mpv.running {
panic("already initialized")
}
Expand Down Expand Up @@ -70,6 +75,16 @@ func (mpv *MPV) initialize() (chan State, int) {
mpv.setOptionString("vo", "null")
mpv.setOptionString("vid", "no")


if *flagPCM != "" {
logger.Println("Writing sound to file: %s", *flagPCM)
mpv.setOptionString("audio-channels", "stereo")
mpv.setOptionString("audio-samplerate", "48000")
mpv.setOptionString("audio-format", "s16")
mpv.setOptionString("ao", "pcm")
mpv.setOptionString("ao-pcm-file", *flagPCM)
}

// Cache settings assume 128kbps audio stream (16kByte/s).
// The default is a cache size of 25MB, these are somewhat more sensible
// cache sizes IMO.
Expand Down Expand Up @@ -231,7 +246,7 @@ func (mpv *MPV) play(stream string, position time.Duration, volume int) {
if !strings.HasPrefix(stream, "https://") {
logger.Panic("Stream does not start with https://...")
}
mpv.sendCommand([]string{"loadfile", "http://localhost:8008/proxy/" + stream[len("https://"):], "replace", options})
mpv.sendCommand([]string{"loadfile", "http://localhost:" + httpPort + "/proxy/" + stream[len("https://"):], "replace", options})
}

func (mpv *MPV) pause() {
Expand Down
10 changes: 9 additions & 1 deletion apps/youtube/youtube.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
"github.com/nu7hatch/gouuid"
)

var logger = log.New("youtube", "log YouTube app")
var logger = log.New("youtube", "Log YouTube app")

// How often a new connection attempt should be done.
// With a starting delay of 500ms that exponentially increases, this is about 5
Expand Down Expand Up @@ -108,6 +108,14 @@ func (yt *YouTube) FriendlyName() string {
return "YouTube"
}

func (yt *YouTube) Data(requestData string) string {
if requestData == "screenid" {
return yt.getScreenId()
}

return ""
}

// Start starts the YouTube app asynchronously.
// Attaches a new device if the app has already started.
func (yt *YouTube) Start(postData string) {
Expand Down
4 changes: 2 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ var configLock sync.Mutex

const CONFIG_FILENAME = ".config/plaincast.json"

var disableConfig = flag.Bool("no-config", false, "disable reading from and writing to config file")
var configPath = flag.String("config", "", "config file location (default "+CONFIG_FILENAME+")")
var disableConfig = flag.Bool("no-config", false, "Disable reading from and writing to config file")
var configPath = flag.String("config", "", "Config file location (default "+CONFIG_FILENAME+")")

// Get returns a global Config instance.
// It may be called multiple times: the same object will be returned each time.
Expand Down
2 changes: 1 addition & 1 deletion log/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const (

var isTerminal = terminal.IsTerminal(int(os.Stdout.Fd()))

var flagLoglevel = flag.String("loglevel", "warn", "baseline loglevel (info, warn, err)")
var flagLoglevel = flag.String("loglevel", "warn", "Baseline loglevel (info, warn, err)")

var loglevel = 0

Expand Down
2 changes: 2 additions & 0 deletions plaincast.service
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ After=network.target sound.target
ExecStart=/usr/local/bin/plaincast -log-mpv -log-youtube -config /var/local/plaincast/plaincast.conf -cachedir /var/local/plaincast/cache
User=plaincast
Group=audio
Restart=on-failure
RestartSec=2s

[Install]
WantedBy=multi-user.target
34 changes: 23 additions & 11 deletions server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,20 @@ import (
// This implements a UPnP/DIAL server.
// DIAL is deprecated, but it's still being used by the YouTube app on Android.

var flagHTTPPort = flag.Int("http-port", 8008, "default http port (0=available)")
var flagHTTPPort = flag.Int("http-port", 8008, "Default http port (0=available)")
var flagInitialApp = flag.String("app", "", "App to run on startup")
var flagFriendlyName = flag.String("friendly-name", "", "Custom friendly name")


// UPnP device description template
const DEVICE_DESCRIPTION = `<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" configId="{{.ConfigId}}">
<specVersion>
<major>1</major>
<minor>1</minor>
<minor>0</minor>
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:dial:1</deviceType>
<deviceType>urn:dial-multiscreen-org:device:dialreceiver:1</deviceType>
<friendlyName>{{.FriendlyName}}</friendlyName>
<manufacturer>-</manufacturer>
<modelDescription>Play the audio of YouTube videos</modelDescription>
Expand All @@ -40,8 +42,8 @@ const DEVICE_DESCRIPTION = `<?xml version="1.0"?>
<UDN>uuid:{{.DeviceUUID}}</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:dail:1</serviceType>
<serviceId>urn:upnp-org:serviceId:dail</serviceId>
<serviceType>urn:dial-multiscreen-org:service:dial:1</serviceType>
<serviceId>urn:dial-multiscreen-org:serviceId:dial</serviceId>
<SCPDURL>/upnp/notfound</SCPDURL>
<controlURL>/upnp/notfound</controlURL>
<eventSubURL></eventSubURL>
Expand All @@ -54,11 +56,14 @@ const DEVICE_DESCRIPTION = `<?xml version="1.0"?>
// DIAL app template
const APP_RESPONSE = `<?xml version="1.0" encoding="UTF-8"?>
<service xmlns="urn:dial-multiscreen-org:schemas:dial" dialVer="1.7">
<name>{{.name}}</name> 
<options allowStop="false"/> 
<state>{{.state}}</state> 
<name>{{.name}}</name>
<options allowStop="false"/>
<state>{{.state}}</state>
{{if .runningUrl}}
<link rel="run" href="{{.runningUrl}}"/>
<link rel="run" href="{{.runningUrl}}"/>
<additionalData>
<screenId>{{.screenid}}</screenId>
</additionalData>
{{end}}
</service>
`
Expand Down Expand Up @@ -100,7 +105,12 @@ func NewUPnPServer() *UPnPServer {
if err != nil {
panic(err)
}
us.friendlyName = FRIENDLY_NAME + " " + hostname

if *flagFriendlyName != "" {
us.friendlyName = *flagFriendlyName
}else{
us.friendlyName = FRIENDLY_NAME + " " + hostname
}

// initialize all known apps
us.apps = make(map[string]apps.App)
Expand Down Expand Up @@ -193,7 +203,8 @@ func (us *UPnPServer) getApplicationURL(req *http.Request) string {
func (us *UPnPServer) serveDescription(w http.ResponseWriter, req *http.Request) {
logger.Println(req.Method, req.URL.Path)

w.Header().Set("Application-URL", us.getApplicationURL(req))

w.Header()["Application-URL"] = []string{us.getApplicationURL(req)}

deviceDescription := map[string]interface{}{
"ConfigId": CONFIGID,
Expand Down Expand Up @@ -291,6 +302,7 @@ func (us *UPnPServer) serveApp(w http.ResponseWriter, req *http.Request) {
"name": appName,
"state": status,
"runningUrl": runningUrl,
"screenid": app.Data("screenid"),
}

w.Header().Set("Content-Type", "text/xml; charset=utf-8")
Expand Down
6 changes: 3 additions & 3 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ const (
)

var deviceUUID *uuid.UUID
var disableSSDP = flag.Bool("no-ssdp", false, "disable SSDP broadcast")
var logger = log.New("server", "log HTTP and SSDP server")
var disableSSDP = flag.Bool("no-ssdp", false, "Disable SSDP server")
var logger = log.New("server", "Log HTTP and SSDP server")

func Serve() {
var err error
Expand All @@ -30,7 +30,7 @@ func Serve() {
if err != nil {
logger.Fatal(err)
}
logger.Println("serving HTTP on port", httpPort)
logger.Println("Serving HTTP on port", httpPort)

if !*disableSSDP {
serveSSDP(httpPort)
Expand Down
28 changes: 20 additions & 8 deletions server/ssdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func serveSSDP(httpPort int) {
panic(err)
}

logger.Println("Listening to SSDP")

// SSDP packets may at most be one UDP packet
buf := make([]byte, UDP_PACKET_SIZE)

Expand All @@ -42,6 +44,7 @@ func serveSSDP(httpPort int) {
continue
}


msg, err := mail.ReadMessage(bytes.NewReader(packet[len(MSEARCH_HEADER):]))
if err != nil {
// ignore malformed packet
Expand All @@ -55,27 +58,31 @@ func serveSSDP(httpPort int) {
// that needs to be responded to.
continue
}

go serveSSDPResponse(msg, raddr, httpPort)

logger.Println("M-SEARCH from %s", raddr)

go serveSSDPResponse(msg, conn, raddr, httpPort)
}

defer conn.Close()
}

func serveSSDPResponse(msg *mail.Message, raddr *net.UDPAddr, httpPort int) {
func serveSSDPResponse(msg *mail.Message, conn *net.UDPConn, raddr *net.UDPAddr, httpPort int) {
mx, err := strconv.Atoi(msg.Header.Get("MX"))

if err != nil {
logger.Warnln("could not parse MX header:", err)
return
}

time.Sleep(time.Duration(rand.Int31n(1000000)) * time.Duration(mx) * time.Microsecond)

conn, err := net.DialUDP("udp", nil, raddr)

// Only for getting local ip
ipconn, err := net.DialUDP("udp", nil, raddr)
if err != nil {
panic(err)
}
defer conn.Close()
defer ipconn.Close()

// TODO implement OS header, BOOTID.UPNP.ORG
// and make this a real template
Expand All @@ -86,10 +93,15 @@ func serveSSDPResponse(msg *mail.Message, raddr *net.UDPAddr, httpPort int) {
"LOCATION: http://%s:%d/upnp/description.xml\r\n"+
"SERVER: Linux/2.6.16+ UPnP/1.1 %s/%s\r\n"+
"ST: urn:dial-multiscreen-org:service:dial:1\r\n"+
"USN: uuid:%s::urn:dial-multiscreen-org:service:dial:1\r\n"+
"CONFIGID.UPNP.ORG: %d\r\n"+
"\r\n", time.Now().Format(time.RFC1123Z), getUrlIP(conn.LocalAddr()), httpPort, NAME, VERSION, CONFIGID)
"\r\n", time.Now().Format(time.RFC1123), getUrlIP(ipconn.LocalAddr()), httpPort, NAME, VERSION, deviceUUID, CONFIGID)

_, err = conn.WriteTo([]byte(response), raddr)

ipconn.Close()
logger.Println("Sent SSDP response")

_, err = conn.Write([]byte(response))
if err != nil {
panic(err)
}
Expand Down