diff --git a/Makefile b/Makefile index 5f971a7..8e630de 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index db9c78d..eb5574a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ This is a small [DIAL](http://www.dial-multiscreen.org) server that emulates Chromecast-like devices, and implements the YouTube app. It only renders the audio, not the video, so it is very lightweight and can run headless. -It can be used as media server, for example on the [Raspberry -Pi](http://www.raspberrypi.org/). +It can be used as media server, for example on the [Raspberry Pi](http://www.raspberrypi.org/). ## Installation @@ -17,11 +16,12 @@ First, make sure you have the needed dependencies installed: * golang 1.3 (1.1+ might also work, but 1.0 certainly doesn't) * libmpv-dev - * youtube-dl (see also 'notes on youtube-dl' below) + * pip3 + * pytube (see 'notes on pytube' below) These can be installed in one go under Debian Jessie: - $ sudo apt-get install golang libmpv-dev youtube-dl + $ sudo apt-get install golang libmpv-dev python3-pip If you haven't already set up a Go workspace, create one now. Some people like to set it to their home directory, but you can also set it to a separate @@ -34,57 +34,78 @@ 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] - $ bin/plaincast +or install it as service -## Notes on youtube-dl + $ cd src/github.com/aykevl/plaincast + $ make install -`youtube-dl` is often too old to be used for downloading YouTube streams. You -can try to run `youtube-dl -U`, but it may say that it won't update because it -has been installed via a package manager. To fix this, uninstall youtube-dl, and -install it via pip. The steps required depend on the version of Python in your -`$PATH` variable. Check it with: +If you want to remove service `$ make remove` - $ python --version +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. -Install using pip for **Python 2** (usually version 2.7.x), on Debian stretch -and below: - $ sudo apt-get remove youtube-dl - $ sudo apt-get install python-pip - $ sudo pip2 install youtube-dl +### Manual service installation -Install using pip3 for **Python 3** (version 3.x). Only required when you have -configured the `python` binary to point to Python 3, or maybe on newer versions -of Debian. +Copy compiled binary file `plaincast` to `/usr/local/bin/` and create new user *plaincast* in group *audio* - $ sudo apt-get remove youtube-dl - $ sudo apt-get install python3-pip - $ sudo pip3 install youtube-dl + $ useradd -s /bin/false -r -M plaincast -g audio + +Create directory -Afterwards, you can update youtube-dl using: + $ mkdir -p /var/local/plaincast + $ chown plaincast:audio /var/local/plaincast - $ sudo pip install --upgrade youtube-dl +Copy systemd unit file `plaincast.service` to `/etc/systemd/system/` and enable the service -Or for Python 3: +`$ systemctl enable plaincast` - $ sudo pip3 install --upgrade youtube-dl -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. +## 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` -## 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). +## Notes on pytube + +Because of youtube_dl beeing awfully slow at fetching stream urls on my raspberry pi 2 I opted +for this alternative approach, which works much faster on this low power platform. + +I tried using python 2 but had no success with it. To install most recent pytube version use pip3! + + $ python3 -m pip install git+https://github.com/nficano/pytube + +It is advisable to run this regularly as it has to keep up with YouTube updates. +Certainly first try updating pytube when plaincast stops working. ## Thanks diff --git a/apps/app.go b/apps/app.go index 80ba1c9..83512c9 100644 --- a/apps/app.go +++ b/apps/app.go @@ -5,4 +5,5 @@ type App interface { Running() bool Quit() FriendlyName() string // return a human-readable name + Data(string) string // return data from app } diff --git a/apps/youtube/mp/mp.go b/apps/youtube/mp/mp.go index 42655b2..ebdf0a9 100644 --- a/apps/youtube/mp/mp.go +++ b/apps/youtube/mp/mp.go @@ -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") diff --git a/apps/youtube/mp/mpv.go b/apps/youtube/mp/mpv.go index 5eb3aaf..02fbf06 100644 --- a/apps/youtube/mp/mpv.go +++ b/apps/youtube/mp/mpv.go @@ -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") } @@ -70,6 +75,19 @@ func (mpv *MPV) initialize() (chan State, int) { mpv.setOptionString("vo", "null") mpv.setOptionString("vid", "no") + + if *flagPCM != "" { + logger.Println("Writing sound to file:", *flagPCM) + + mpv.setOptionString("audio-channels", "stereo") + mpv.setOptionString("audio-samplerate", "48000") + mpv.setOptionString("audio-format", "s16") + mpv.setOptionString("ao", "pcm") + mpv.setOptionString("ao-pcm-waveheader", "no") + mpv.setOptionString("ao-pcm-append", "yes") + 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. @@ -231,7 +249,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() { diff --git a/apps/youtube/mp/youtube.go b/apps/youtube/mp/youtube.go index 44d0ce1..f4a0513 100644 --- a/apps/youtube/mp/youtube.go +++ b/apps/youtube/mp/youtube.go @@ -14,31 +14,19 @@ import ( const pythonGrabber = ` try: import sys - from youtube_dl import YoutubeDL - from youtube_dl.utils import DownloadError - - if len(sys.argv) != 3: - sys.stderr.write('arguments: ') - os.exit(1) - - yt = YoutubeDL({ - 'geturl': True, - 'format': sys.argv[1], - 'cachedir': sys.argv[2] or None, - 'quiet': True, - 'simulate': True}) + import pytube while True: stream = '' try: url = sys.stdin.readline().strip() - stream = yt.extract_info(url, ie_key='Youtube')['url'] + stream = pytube.YouTube(str(url)).streams.first().url except (KeyboardInterrupt, EOFError, IOError): break - except DownloadError as why: - # error message has already been printed - sys.stderr.write('Could not extract video, try updating youtube-dl.\n') - finally: + except pytube.exceptions.ExtractError: + str = 'Could not extract video: ' + str(url) + '\n' + sys.stderr.write(str) + finally: try: sys.stdout.write(stream + '\n') sys.stdout.flush() @@ -74,17 +62,17 @@ func NewVideoGrabber() *VideoGrabber { vg := VideoGrabber{} vg.streams = make(map[string]*VideoURL) - cacheDir := *cacheDir - if cacheDir != "" { - cacheDir = cacheDir + "/" + "youtube-dl" - } + //cacheDir := *cacheDir + //if cacheDir != "" { + // cacheDir = cacheDir + "/" + "youtube-dl" + //} // Start the process in a separate goroutine. vg.cmdMutex.Lock() go func() { defer vg.cmdMutex.Unlock() - vg.cmd = exec.Command("python", "-c", pythonGrabber, grabberFormats, cacheDir) + vg.cmd = exec.Command("python3", "-c", pythonGrabber)//, grabberFormats, cacheDir) stdout, err := vg.cmd.StdoutPipe() if err != nil { logger.Fatal(err) diff --git a/apps/youtube/youtube.go b/apps/youtube/youtube.go index 74ed7d5..c63d26c 100644 --- a/apps/youtube/youtube.go +++ b/apps/youtube/youtube.go @@ -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 @@ -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) { diff --git a/config/config.go b/config/config.go index e552fb2..b5bf0e6 100644 --- a/config/config.go +++ b/config/config.go @@ -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. diff --git a/log/log.go b/log/log.go index 0b344e3..5b22b7b 100644 --- a/log/log.go +++ b/log/log.go @@ -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 diff --git a/plaincast.service b/plaincast.service index 97739e0..96a3003 100644 --- a/plaincast.service +++ b/plaincast.service @@ -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 diff --git a/server/http.go b/server/http.go index 30e7081..79967b0 100644 --- a/server/http.go +++ b/server/http.go @@ -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 = ` 1 - 1 + 0 - urn:schemas-upnp-org:device:dial:1 + urn:dial-multiscreen-org:device:dialreceiver:1 {{.FriendlyName}} - Play the audio of YouTube videos @@ -40,8 +42,8 @@ const DEVICE_DESCRIPTION = ` uuid:{{.DeviceUUID}} - urn:schemas-upnp-org:service:dail:1 - urn:upnp-org:serviceId:dail + urn:dial-multiscreen-org:service:dial:1 + urn:dial-multiscreen-org:serviceId:dial /upnp/notfound /upnp/notfound @@ -54,11 +56,14 @@ const DEVICE_DESCRIPTION = ` // DIAL app template const APP_RESPONSE = ` - {{.name}}  -   - {{.state}}  +{{.name}} + +{{.state}} {{if .runningUrl}} - + + +{{.screenid}} + {{end}} ` @@ -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) @@ -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, @@ -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") diff --git a/server/server.go b/server/server.go index b8d7116..1ad696c 100644 --- a/server/server.go +++ b/server/server.go @@ -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 @@ -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) diff --git a/server/ssdp.go b/server/ssdp.go index b9083e2..97790f6 100644 --- a/server/ssdp.go +++ b/server/ssdp.go @@ -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) @@ -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 @@ -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", 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 @@ -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) }