├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── client.go ├── client_test.go ├── connection.go ├── device.go ├── discovery.go ├── discovery_test.go ├── errors.go └── example ├── devices ├── README.md └── main.go ├── player └── main.go ├── seeker └── main.go └── slideshow └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | # examples 2 | example/devices/devices 3 | example/player/player 4 | example/slideshow/slideshow 5 | example/seeker/seeker 6 | 7 | # tests 8 | go-airplay.test 9 | 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | sudo: false 4 | 5 | go: 6 | - 1.7 7 | 8 | branches: 9 | only: 10 | - master 11 | 12 | install: 13 | - go get github.com/miekg/dns 14 | - go get github.com/gongo/text-parameters 15 | - go get github.com/DHowett/go-plist 16 | - go get github.com/mattn/goveralls 17 | 18 | script: 19 | - go test -covermode=count -coverprofile=profile.cov 20 | - $HOME/gopath/bin/goveralls -coverprofile=profile.cov -service=travis-ci 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Wataru MIYAGUNI 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-airplay 2 | ========== 3 | 4 | [![GoDoc](http://godoc.org/github.com/gongo/go-airplay?status.svg)](http://godoc.org/github.com/gongo/go-airplay) 5 | [![Build Status](https://travis-ci.org/gongo/go-airplay.svg?branch=master)](https://travis-ci.org/gongo/go-airplay) 6 | [![Coverage Status](https://coveralls.io/repos/gongo/go-airplay/badge.png?branch=master)](https://coveralls.io/r/gongo/go-airplay?branch=master) 7 | 8 | Go bindings for AirPlay client 9 | 10 | ## Usage 11 | 12 | ### Videos 13 | 14 | ```go 15 | import "github.com/gongo/go-airplay" 16 | 17 | client, err := airplay.FirstClient() 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | ch := client.Play("http://movie.example.com/go.mp4") 23 | 24 | // Block until have played content to the end 25 | <-ch 26 | ``` 27 | 28 | If device is required password: 29 | 30 | ```go 31 | client.SetPassword("password") 32 | ``` 33 | 34 | Specifying the start position: 35 | 36 | ```go 37 | // Start from 42% of length of content. 38 | client.PlayAt("http://movie.example.com/go.mp4", 0.42) 39 | ``` 40 | 41 | Other API: 42 | 43 | ```go 44 | // Seek to 120 seconds from start position. 45 | client.Scrub(120.0) 46 | 47 | // Change playback rate 48 | client.Rate(0.0) // pause 49 | client.Rate(1.0) // resume 50 | ``` 51 | 52 | See: 53 | 54 | - [example/player](./example/player/main.go) 55 | - [example/seeker](./example/seeker/main.go) 56 | 57 | ### Images 58 | 59 | ```go 60 | // local file 61 | client.Photo("/path/to/gopher.jpg") 62 | 63 | // remote file 64 | client.Photo("http://blog.golang.org/gopher/plush.jpg") 65 | ``` 66 | 67 | You can specify the transition want to slide: 68 | 69 | ```go 70 | client.Photo("/path/to/gopher.jpg", airplay.SlideNone) // eq client.Photo("..") 71 | client.Photo("/path/to/gopher.jpg", airplay.SlideDissolve) 72 | client.Photo("/path/to/gopher.jpg", airplay.SlideRight) 73 | client.Photo("/path/to/gopher.jpg", airplay.SlideLeft) 74 | ``` 75 | 76 | See [example/slideshow](./example/slideshow/main.go) : 77 | 78 | ### Devices 79 | 80 | ```go 81 | // Get all AirPlay devices in LAN. 82 | devices := airplay.Devices() 83 | 84 | // Get the first found AirPlay device in LAN. 85 | device := airplay.FirstDevice() 86 | ``` 87 | 88 | See [example/devices](./example/devices/) : 89 | 90 | ## LICENSE 91 | 92 | [MIT License](./LICENSE.txt). 93 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package airplay 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "strings" 14 | "time" 15 | 16 | "github.com/DHowett/go-plist" 17 | ) 18 | 19 | // A PlaybackInfo is a playback information of playing content. 20 | type PlaybackInfo struct { 21 | // IsReadyToPlay, if true, content is currently playing or ready to play. 22 | IsReadyToPlay bool `plist:"readyToPlay"` 23 | 24 | // ReadyToPlayValue represents the information on whether content is currently playing, ready to play or not. 25 | ReadyToPlayValue interface{} `plist:"readyToPlay"` 26 | 27 | // Duration represents playback duration in seconds. 28 | Duration float64 `plist:"duration"` 29 | 30 | // Position represents playback position in seconds. 31 | Position float64 `plist:"position"` 32 | } 33 | 34 | type Client struct { 35 | connection *connection 36 | } 37 | 38 | // SlideTransition represents transition that used when show the picture. 39 | type SlideTransition string 40 | 41 | const ( 42 | SlideNone SlideTransition = "None" 43 | SlideDissolve SlideTransition = "Dissolve" 44 | SlideLeft SlideTransition = "SlideLeft" 45 | SlideRight SlideTransition = "SlideRight" 46 | ) 47 | 48 | var ( 49 | requestInverval = time.Second 50 | ) 51 | 52 | type ClientParam struct { 53 | Addr string 54 | Port int 55 | Password string 56 | } 57 | 58 | // FirstClient return the AirPlay Client that has the first found AirPlay device in LAN 59 | func FirstClient() (*Client, error) { 60 | device := FirstDevice() 61 | if device.Name == "" { 62 | return nil, errors.New("AirPlay devices not found") 63 | } 64 | 65 | return &Client{connection: newConnection(device)}, nil 66 | } 67 | 68 | func NewClient(params *ClientParam) (*Client, error) { 69 | if params.Addr == "" { 70 | return nil, errors.New("airplay: [ERR] Address is required to NewClient()") 71 | } 72 | 73 | if params.Port <= 0 { 74 | params.Port = 7000 75 | } 76 | 77 | client := &Client{} 78 | device := Device{Addr: params.Addr, Port: params.Port} 79 | client.connection = newConnection(device) 80 | 81 | if params.Password != "" { 82 | client.SetPassword(params.Password) 83 | } 84 | 85 | return client, nil 86 | } 87 | 88 | func (c Client) SetPassword(password string) { 89 | c.connection.setPassword(password) 90 | } 91 | 92 | // Play start content playback. 93 | // 94 | // When playback is finished, sends termination status on the returned channel. 95 | // If non-nil, not a successful termination. 96 | func (c *Client) Play(url string) <-chan error { 97 | return c.PlayAt(url, 0.0) 98 | } 99 | 100 | // PlayAt start content playback by specifying the start position. 101 | // 102 | // Returned channel is the same as Play(). 103 | func (c *Client) PlayAt(url string, position float64) <-chan error { 104 | ch := make(chan error, 1) 105 | body := fmt.Sprintf("Content-Location: %s\nStart-Position: %f\n", url, position) 106 | 107 | go func() { 108 | if _, err := c.connection.post("play", strings.NewReader(body)); err != nil { 109 | ch <- err 110 | return 111 | } 112 | 113 | if err := c.waitForReadyToPlay(); err != nil { 114 | ch <- err 115 | return 116 | } 117 | 118 | interval := time.Tick(requestInverval) 119 | 120 | for { 121 | info, err := c.GetPlaybackInfo() 122 | 123 | if err != nil { 124 | ch <- err 125 | return 126 | } 127 | 128 | if !info.IsReadyToPlay { 129 | break 130 | } 131 | 132 | <-interval 133 | } 134 | 135 | ch <- nil 136 | }() 137 | 138 | return ch 139 | } 140 | 141 | // Stop exits content playback. 142 | func (c *Client) Stop() { 143 | c.connection.post("stop", nil) 144 | } 145 | 146 | // Scrub seeks at position seconds in playing content. 147 | func (c *Client) Scrub(position float64) { 148 | query := fmt.Sprintf("?position=%f", position) 149 | c.connection.post("scrub"+query, nil) 150 | } 151 | 152 | // Rate change the playback rate in playing content. 153 | // 154 | // If rate is 0, content is paused. 155 | // if rate is 1, content playing at the normal speed. 156 | func (c *Client) Rate(rate float64) { 157 | query := fmt.Sprintf("?value=%f", rate) 158 | c.connection.post("rate"+query, nil) 159 | } 160 | 161 | // Photo show a JPEG picture. It can specify both remote or local file. 162 | // 163 | // A trivial example: 164 | // 165 | // // local file 166 | // client.Photo("/path/to/gopher.jpg") 167 | // 168 | // // remote file 169 | // client.Photo("http://blog.golang.org/gopher/plush.jpg") 170 | // 171 | func (c *Client) Photo(path string) { 172 | c.PhotoWithSlide(path, SlideNone) 173 | } 174 | 175 | // PhotoWithSlide show a JPEG picture in the transition specified. 176 | func (c *Client) PhotoWithSlide(path string, transition SlideTransition) { 177 | url, err := url.Parse(path) 178 | if err != nil { 179 | log.Fatal(err) 180 | } 181 | 182 | var image *bytes.Reader 183 | 184 | if url.Scheme == "http" || url.Scheme == "https" { 185 | image, err = remoteImageReader(path) 186 | } else { 187 | image, err = localImageReader(path) 188 | } 189 | if err != nil { 190 | log.Fatal(err) 191 | } 192 | 193 | header := http.Header{ 194 | "X-Apple-Transition": {string(transition)}, 195 | } 196 | c.connection.postWithHeader("photo", image, header) 197 | } 198 | 199 | // GetPlaybackInfo retrieves playback informations. 200 | func (c *Client) GetPlaybackInfo() (*PlaybackInfo, error) { 201 | response, err := c.connection.get("playback-info") 202 | if err != nil { 203 | return nil, err 204 | } 205 | defer response.Body.Close() 206 | 207 | body, err := convertBytesReader(response.Body) 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | decoder := plist.NewDecoder(body) 213 | info := &PlaybackInfo{} 214 | if err := decoder.Decode(info); err != nil { 215 | return nil, err 216 | } 217 | 218 | switch t := info.ReadyToPlayValue.(type) { 219 | case uint64: // AppleTV 4G 220 | info.IsReadyToPlay = (t == 1) 221 | case bool: // AppleTV 2G, 3G 222 | info.IsReadyToPlay = t 223 | } 224 | 225 | return info, nil 226 | } 227 | 228 | func (c *Client) waitForReadyToPlay() error { 229 | interval := time.Tick(requestInverval) 230 | timeout := time.After(10 * time.Second) 231 | 232 | for { 233 | select { 234 | case <-timeout: 235 | return errors.New("timeout while waiting for ready to play") 236 | case <-interval: 237 | info, err := c.GetPlaybackInfo() 238 | 239 | if err != nil { 240 | return err 241 | } 242 | 243 | if info.IsReadyToPlay { 244 | return nil 245 | } 246 | } 247 | } 248 | } 249 | 250 | func localImageReader(path string) (*bytes.Reader, error) { 251 | fn, err := os.Open(path) 252 | if err != nil { 253 | return nil, err 254 | } 255 | defer fn.Close() 256 | 257 | return convertBytesReader(fn) 258 | } 259 | 260 | func remoteImageReader(url string) (*bytes.Reader, error) { 261 | response, err := http.Get(url) 262 | if err != nil { 263 | return nil, err 264 | } 265 | defer response.Body.Close() 266 | 267 | return convertBytesReader(response.Body) 268 | } 269 | 270 | func convertBytesReader(r io.Reader) (*bytes.Reader, error) { 271 | body, err := ioutil.ReadAll(r) 272 | if err != nil { 273 | return nil, err 274 | } 275 | 276 | return bytes.NewReader(body), nil 277 | } 278 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package airplay 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "os" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "testing" 13 | 14 | "time" 15 | 16 | "github.com/gongo/text-parameters" 17 | ) 18 | 19 | type testHundelrFunc func(*testing.T, http.ResponseWriter, *http.Request) 20 | 21 | type testExpectRequest struct { 22 | method string 23 | path string 24 | } 25 | 26 | func (e testExpectRequest) isMatch(method, path string) bool { 27 | return (e.method == method && e.path == path) 28 | } 29 | 30 | var ( 31 | stopPlaybackInfo = ` 32 | 33 | 34 | 35 | readyToPlay 36 | 37 | uuid 38 | AAAAA-BBBBB-CCCCC-DDDDD-EEEEE 39 | 40 | ` 41 | 42 | playingPlaybackInfo = ` 43 | 44 | 45 | 46 | duration 47 | 36.00000 48 | position 49 | 18.00000 50 | readyToPlay 51 | 52 | 53 | ` 54 | 55 | playingPlaybackInfoAt4G = ` 56 | 57 | 58 | 59 | readyToPlay 60 | 1 61 | 62 | ` 63 | ) 64 | 65 | type playbackInfoParam struct { 66 | Location string `parameters:"Content-Location"` 67 | Position float64 `parameters:"Start-Position"` 68 | } 69 | 70 | func TestMain(m *testing.M) { 71 | requestInverval = time.Millisecond 72 | os.Exit(m.Run()) 73 | } 74 | 75 | func TestPost(t *testing.T) { 76 | expectRequests := []testExpectRequest{ 77 | {"POST", "/play"}, 78 | {"GET", "/playback-info"}, 79 | {"GET", "/playback-info"}, 80 | {"GET", "/playback-info"}, 81 | } 82 | responseXMLs := []string{ 83 | stopPlaybackInfo, 84 | playingPlaybackInfo, 85 | stopPlaybackInfo, 86 | } 87 | 88 | ts := airTestServer(t, expectRequests, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 89 | if req.URL.Path == "/play" { 90 | u := &playbackInfoParam{} 91 | decoder := parameters.NewDecorder(req.Body) 92 | decoder.Decode(u) 93 | 94 | if u.Location != "http://movie.example.com/go.mp4" { 95 | t.Fatalf("Incorrect request location (actual %s)", u.Location) 96 | } 97 | 98 | if u.Position != 0.0 { 99 | t.Fatalf("Incorrect request position (actual %f)", u.Position) 100 | } 101 | } 102 | 103 | if req.URL.Path == "/playback-info" { 104 | xml := responseXMLs[0] 105 | responseXMLs = responseXMLs[1:] 106 | w.Write([]byte(xml)) 107 | } 108 | }) 109 | 110 | client := getTestClient(t, ts) 111 | ch := client.Play("http://movie.example.com/go.mp4") 112 | <-ch 113 | } 114 | 115 | func TestPostAt(t *testing.T) { 116 | expectRequests := []testExpectRequest{ 117 | {"POST", "/play"}, 118 | {"GET", "/playback-info"}, 119 | {"GET", "/playback-info"}, 120 | } 121 | responseXMLs := []string{ 122 | playingPlaybackInfo, 123 | stopPlaybackInfo, 124 | } 125 | 126 | ts := airTestServer(t, expectRequests, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 127 | if req.URL.Path == "/play" { 128 | u := &playbackInfoParam{} 129 | decoder := parameters.NewDecorder(req.Body) 130 | decoder.Decode(u) 131 | 132 | if u.Position != 12.3 { 133 | t.Fatalf("Incorrect request position (actual %f)", u.Position) 134 | } 135 | } 136 | 137 | if req.URL.Path == "/playback-info" { 138 | xml := responseXMLs[0] 139 | responseXMLs = responseXMLs[1:] 140 | w.Write([]byte(xml)) 141 | } 142 | }) 143 | 144 | client := getTestClient(t, ts) 145 | ch := client.PlayAt("http://movie.example.com/go.mp4", 12.3) 146 | <-ch 147 | } 148 | 149 | func TestStop(t *testing.T) { 150 | ts := airTestServer(t, []testExpectRequest{{"POST", "/stop"}}, nil) 151 | client := getTestClient(t, ts) 152 | client.Stop() 153 | } 154 | 155 | func TestScrub(t *testing.T) { 156 | position := 12.345 157 | ts := airTestServer(t, []testExpectRequest{{"POST", "/scrub"}}, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 158 | values := req.URL.Query() 159 | positionString := values.Get("position") 160 | if positionString == "" { 161 | t.Fatal("Not found query parameter `position`") 162 | } 163 | 164 | positionFloat, err := strconv.ParseFloat(positionString, 64) 165 | if err != nil { 166 | t.Fatalf("Incorrect query parameter `position` (actual = %s)", positionString) 167 | } 168 | 169 | if positionFloat != position { 170 | t.Fatalf("Incorrect query parameter `position` (actual = %f)", positionFloat) 171 | } 172 | }) 173 | client := getTestClient(t, ts) 174 | client.Scrub(position) 175 | } 176 | 177 | func TestRate(t *testing.T) { 178 | rate := 0.8 179 | ts := airTestServer(t, []testExpectRequest{{"POST", "/rate"}}, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 180 | rateString := req.URL.Query().Get("value") 181 | if rateString == "" { 182 | t.Fatal("Not found query parameter `value`") 183 | } 184 | 185 | rateFloat, err := strconv.ParseFloat(rateString, 64) 186 | if err != nil { 187 | t.Fatalf("Incorrect query parameter `value` (actual = %s)", rateString) 188 | } 189 | 190 | if rateFloat != rate { 191 | t.Fatalf("Incorrect query parameter `value` (actual = %f)", rateFloat) 192 | } 193 | }) 194 | client := getTestClient(t, ts) 195 | client.Rate(rate) 196 | } 197 | 198 | func TestPhotoLocalFile(t *testing.T) { 199 | dir := os.TempDir() 200 | 201 | f, err := ioutil.TempFile(dir, "photo_test") 202 | if err != nil { 203 | t.Fatal(err) 204 | } 205 | defer f.Close() 206 | defer os.Remove(f.Name()) 207 | 208 | f.WriteString("localfile") 209 | 210 | ts := airTestServer(t, []testExpectRequest{{"POST", "/photo"}}, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 211 | if req.Header.Get("X-Apple-Transition") != "None" { 212 | t.Fatalf("Incorrect request header (actual = %s)", req.Header.Get("X-Apple-Transition")) 213 | } 214 | 215 | bytes, err := ioutil.ReadAll(req.Body) 216 | if err != nil { 217 | t.Fatal(err) 218 | } 219 | 220 | body := string(bytes) 221 | if body != "localfile" { 222 | t.Fatalf("Incorrect request body (actual = %s)", body) 223 | } 224 | }) 225 | 226 | client := getTestClient(t, ts) 227 | client.Photo(f.Name()) 228 | } 229 | 230 | func TestPhotoRemoteFile(t *testing.T) { 231 | remoteTs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 232 | body := []byte("remotefile") 233 | w.Write(body) 234 | })) 235 | 236 | ts := airTestServer(t, []testExpectRequest{{"POST", "/photo"}}, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 237 | bytes, err := ioutil.ReadAll(req.Body) 238 | if err != nil { 239 | t.Fatal(err) 240 | } 241 | 242 | body := string(bytes) 243 | if body != "remotefile" { 244 | t.Fatalf("Incorrect request body (actual = %s)", body) 245 | } 246 | }) 247 | 248 | client := getTestClient(t, ts) 249 | client.Photo(remoteTs.URL) 250 | } 251 | 252 | func TestPhotoWithSlide(t *testing.T) { 253 | remoteTs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 254 | body := []byte("remotefile") 255 | w.Write(body) 256 | })) 257 | 258 | ts := airTestServer(t, []testExpectRequest{{"POST", "/photo"}}, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 259 | if req.Header.Get("X-Apple-Transition") != "SlideRight" { 260 | t.Fatalf("Incorrect request header (actual = %s)", req.Header.Get("X-Apple-Transition")) 261 | } 262 | }) 263 | 264 | client := getTestClient(t, ts) 265 | client.PhotoWithSlide(remoteTs.URL, SlideRight) 266 | } 267 | 268 | func TestGetPlaybackInfo(t *testing.T) { 269 | expectRequests := []testExpectRequest{ 270 | {"GET", "/playback-info"}, 271 | {"GET", "/playback-info"}, 272 | } 273 | responseXMLs := []string{stopPlaybackInfo, playingPlaybackInfo} 274 | 275 | ts := airTestServer(t, expectRequests, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 276 | xml := responseXMLs[0] 277 | responseXMLs = responseXMLs[1:] 278 | w.Write([]byte(xml)) 279 | }) 280 | 281 | client := getTestClient(t, ts) 282 | 283 | info, err := client.GetPlaybackInfo() 284 | if err != nil { 285 | t.Fatal(err) 286 | } 287 | 288 | if info.IsReadyToPlay { 289 | t.Fatal("PlaybackInfo is not ready to play status") 290 | } 291 | 292 | info, err = client.GetPlaybackInfo() 293 | if err != nil { 294 | t.Fatal(err) 295 | } 296 | 297 | if info.Duration != 36.0 || info.Position != 18.0 { 298 | t.Fatal("Incorrect PlaybackInfo") 299 | } 300 | } 301 | 302 | func TestGetPlaybackInfoWithVariousVersion(t *testing.T) { 303 | expectRequests := []testExpectRequest{ 304 | {"GET", "/playback-info"}, 305 | {"GET", "/playback-info"}, 306 | } 307 | responseXMLs := []string{playingPlaybackInfo, playingPlaybackInfoAt4G} 308 | 309 | ts := airTestServer(t, expectRequests, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 310 | xml := responseXMLs[0] 311 | responseXMLs = responseXMLs[1:] 312 | w.Write([]byte(xml)) 313 | }) 314 | 315 | client := getTestClient(t, ts) 316 | 317 | for range responseXMLs { 318 | info, err := client.GetPlaybackInfo() 319 | if err != nil { 320 | t.Fatal(err) 321 | } 322 | 323 | if !info.IsReadyToPlay { 324 | t.Fatal("PlaybackInfo is not ready to play status") 325 | } 326 | } 327 | } 328 | 329 | func TestClientToPasswordRequiredDevice(t *testing.T) { 330 | expectRequests := []testExpectRequest{ 331 | {"POST", "/play"}, 332 | {"POST", "/play"}, 333 | {"GET", "/playback-info"}, 334 | {"GET", "/playback-info"}, 335 | } 336 | responseXMLs := []string{ 337 | playingPlaybackInfo, 338 | stopPlaybackInfo, 339 | } 340 | playRequestCount := 1 341 | 342 | ts := airTestServer(t, expectRequests, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 343 | switch req.URL.Path { 344 | case "/play": 345 | switch playRequestCount { 346 | case 1: 347 | w.Header().Add("WWW-Authenticate", "Digest realm=\"AirPlay\", nonce=\"4444\"") 348 | w.WriteHeader(http.StatusUnauthorized) 349 | case 2: 350 | pattern := regexp.MustCompile("^Digest .*response=\"([^\"]+)\"") 351 | results := pattern.FindStringSubmatch(req.Header.Get("Authorization")) 352 | if results == nil { 353 | t.Fatalf("Unexpected request: %s", req.Header.Get("Authorization")) 354 | } 355 | 356 | // response was created on the assumption that password is "gongo" 357 | expect := "f53f5ad052f58cee48f550b9632e0446" 358 | actual := results[1] 359 | if expect != actual { 360 | t.Fatalf("Incorrect authorization header (actual %s)", actual) 361 | } 362 | default: 363 | t.Fatal("Surplus request has occurs") 364 | } 365 | 366 | playRequestCount++ 367 | case "/playback-info": 368 | xml := responseXMLs[0] 369 | responseXMLs = responseXMLs[1:] 370 | w.Write([]byte(xml)) 371 | } 372 | }) 373 | 374 | client := getTestClient(t, ts) 375 | client.SetPassword("gongo") 376 | ch := client.Play("http://movie.example.com/go.mp4") 377 | if err := <-ch; err != nil { 378 | t.Fatal(err) 379 | } 380 | } 381 | 382 | func TestClientWithErrorAboutPassword(t *testing.T) { 383 | expectRequests := []testExpectRequest{ 384 | {"POST", "/play"}, 385 | {"POST", "/play"}, 386 | {"POST", "/play"}, 387 | } 388 | playRequestCount := 1 389 | 390 | ts := airTestServer(t, expectRequests, func(t *testing.T, w http.ResponseWriter, req *http.Request) { 391 | switch playRequestCount { 392 | case 1, 2: 393 | w.Header().Add("WWW-Authenticate", "Digest realm=\"AirPlay\", nonce=\"4444\"") 394 | w.WriteHeader(http.StatusUnauthorized) 395 | case 3: 396 | pattern := regexp.MustCompile("^Digest .*response=\"([^\"]+)\"") 397 | results := pattern.FindStringSubmatch(req.Header.Get("Authorization")) 398 | if results == nil { 399 | t.Fatalf("Unexpected request: %s", req.Header.Get("Authorization")) 400 | } 401 | 402 | // response was created on the assumption that password is "gongo" 403 | expect := "f53f5ad052f58cee48f550b9632e0446" 404 | actual := results[1] 405 | if expect != actual { 406 | w.Header().Add("WWW-Authenticate", "Digest realm=\"AirPlay\", nonce=\"4444\"") 407 | w.WriteHeader(http.StatusUnauthorized) 408 | } 409 | default: 410 | t.Fatal("Surplus request has occurs") 411 | } 412 | 413 | playRequestCount++ 414 | }) 415 | 416 | var ch <-chan error 417 | 418 | client := getTestClient(t, ts) 419 | ch = client.Play("http://movie.example.com/go.mp4") 420 | if err := <-ch; err == nil { 421 | t.Fatal("It should occurs [password required] error") 422 | } 423 | 424 | client.SetPassword("wrongpassword") 425 | ch = client.Play("http://movie.example.com/go.mp4") 426 | if err := <-ch; err == nil { 427 | t.Fatal("It should occurs [wrong password] error") 428 | } 429 | } 430 | 431 | func airTestServer(t *testing.T, requests []testExpectRequest, handler testHundelrFunc) *httptest.Server { 432 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 433 | if len(requests) == 0 { 434 | t.Fatal("Incorrect request count") 435 | } 436 | 437 | expect := requests[0] 438 | requests = requests[1:] 439 | 440 | if !expect.isMatch(req.Method, req.URL.Path) { 441 | t.Fatalf("request is not '%s %s' (actual = %s %s)", 442 | expect.method, expect.path, req.Method, req.URL.Path) 443 | } 444 | 445 | if handler != nil { 446 | handler(t, w, req) 447 | } 448 | })) 449 | } 450 | 451 | func getTestClient(t *testing.T, ts *httptest.Server) *Client { 452 | addr, port := getAddrAndPort(t, ts.URL) 453 | client, err := NewClient(&ClientParam{Addr: addr, Port: port}) 454 | if err != nil { 455 | t.Fatal(err) 456 | } 457 | 458 | return client 459 | } 460 | 461 | func getAddrAndPort(t *testing.T, host string) (string, int) { 462 | u, err := url.Parse(host) 463 | if err != nil { 464 | t.Fatal(err) 465 | } 466 | 467 | split := strings.Split(u.Host, ":") 468 | addr := split[0] 469 | port, err := strconv.Atoi(split[1]) 470 | if err != nil { 471 | t.Fatal(err) 472 | } 473 | 474 | return addr, port 475 | } 476 | -------------------------------------------------------------------------------- /connection.go: -------------------------------------------------------------------------------- 1 | package airplay 2 | 3 | import ( 4 | "crypto/md5" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "regexp" 10 | ) 11 | 12 | const ( 13 | digestAuthUsername = "AirPlay" 14 | digestAuthRealm = "AirPlay" 15 | ) 16 | 17 | type connection struct { 18 | device Device 19 | passwordHash string 20 | } 21 | 22 | func newConnection(device Device) *connection { 23 | return &connection{device: device} 24 | } 25 | 26 | func (c *connection) setPassword(password string) { 27 | c.passwordHash = fmt.Sprintf("%x", md5.Sum([]byte(digestAuthUsername+":"+digestAuthRealm+":"+password))) 28 | } 29 | 30 | func (c *connection) get(path string) (*http.Response, error) { 31 | return c.getWithHeader(path, http.Header{}) 32 | } 33 | 34 | func (c *connection) getWithHeader(path string, header http.Header) (*http.Response, error) { 35 | return c.request("GET", path, nil, header) 36 | } 37 | 38 | func (c *connection) post(path string, body io.ReadSeeker) (*http.Response, error) { 39 | return c.postWithHeader(path, body, http.Header{}) 40 | } 41 | 42 | func (c *connection) postWithHeader(path string, body io.ReadSeeker, header http.Header) (*http.Response, error) { 43 | return c.request("POST", path, body, header) 44 | } 45 | 46 | func (c *connection) request(method, path string, body io.ReadSeeker, header http.Header) (*http.Response, error) { 47 | response, err := c.do(method, path, body, header) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | if response.StatusCode == http.StatusUnauthorized { 53 | if c.passwordHash == "" { 54 | msg := fmt.Sprintf( 55 | "airplay: [ERR] Device %s:%d is required password", 56 | c.device.Addr, 57 | c.device.Port, 58 | ) 59 | return nil, errors.New(msg) 60 | } 61 | 62 | token := c.authorizationHeader(response, method, path, header) 63 | 64 | // body is closed first c.do(). 65 | body.Seek(0, 0) 66 | header.Add("Authorization", token) 67 | response, err = c.do(method, path, body, header) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | if response.StatusCode == http.StatusUnauthorized { 73 | msg := fmt.Sprintf( 74 | "airplay: [ERR] Wrong password to %s:%d", 75 | c.device.Addr, 76 | c.device.Port, 77 | ) 78 | return nil, errors.New(msg) 79 | } 80 | } 81 | 82 | return response, nil 83 | } 84 | 85 | func (c *connection) do(method, path string, body io.ReadSeeker, header http.Header) (*http.Response, error) { 86 | req, err := http.NewRequest(method, c.endpoint()+path, body) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | req.Header = header 92 | client := &http.Client{} 93 | response, err := client.Do(req) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return response, nil 99 | } 100 | 101 | func (c *connection) endpoint() string { 102 | return fmt.Sprintf("http://%s:%d/", c.device.Addr, c.device.Port) 103 | } 104 | 105 | func (c *connection) authorizationHeader(response *http.Response, method, path string, header http.Header) string { 106 | pattern := regexp.MustCompile("^Digest .*nonce=\"([^\"]+)\"") 107 | results := pattern.FindStringSubmatch(response.Header.Get("Www-Authenticate")) 108 | if results == nil { 109 | return "" 110 | } 111 | 112 | nonce := results[1] 113 | a1 := c.passwordHash 114 | a2 := fmt.Sprintf("%x", md5.Sum([]byte(method+":/"+path))) 115 | resp := fmt.Sprintf("%x", md5.Sum([]byte(a1+":"+nonce+":"+a2))) 116 | 117 | return fmt.Sprintf( 118 | "Digest username=\"%s\", realm=\"%s\", uri=\"/%s\", nonce=\"%s\", response=\"%s\"", 119 | digestAuthUsername, 120 | digestAuthRealm, 121 | path, 122 | nonce, 123 | resp, 124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /device.go: -------------------------------------------------------------------------------- 1 | package airplay 2 | 3 | // A Device is an AirPlay Device. 4 | type Device struct { 5 | Name string 6 | Addr string 7 | Port int 8 | Extra DeviceExtra 9 | } 10 | 11 | // A DeviceExtra is extra information of AirPlay device. 12 | type DeviceExtra struct { 13 | Model string 14 | Features string 15 | MacAddress string 16 | ServerVersion string 17 | IsPasswordRequired bool 18 | } 19 | 20 | // Devices returns all AirPlay devices in LAN. 21 | func Devices() []Device { 22 | devices := []Device{} 23 | 24 | for _, entry := range searchEntry(&queryParam{}) { 25 | devices = append( 26 | devices, 27 | entryToDevice(entry), 28 | ) 29 | } 30 | 31 | return devices 32 | } 33 | 34 | // FirstDevice return the first found AirPlay device in LAN. 35 | func FirstDevice() Device { 36 | params := &queryParam{maxCount: 1} 37 | 38 | for _, entry := range searchEntry(params) { 39 | return entryToDevice(entry) 40 | } 41 | 42 | return Device{} 43 | } 44 | 45 | func entryToDevice(entry *entry) Device { 46 | extra := DeviceExtra{ 47 | Model: entry.textRecords["model"], 48 | Features: entry.textRecords["features"], 49 | MacAddress: entry.textRecords["deviceid"], 50 | ServerVersion: entry.textRecords["srcvers"], 51 | IsPasswordRequired: false, 52 | } 53 | 54 | if pw, ok := entry.textRecords["pw"]; ok && pw == "1" { 55 | extra.IsPasswordRequired = true 56 | } 57 | 58 | return Device{ 59 | Name: entry.hostName, 60 | Addr: entry.ipv4.String(), 61 | Port: int(entry.port), 62 | Extra: extra, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /discovery.go: -------------------------------------------------------------------------------- 1 | package airplay 2 | 3 | // discovery.go was created in reference to github.com/armon/mdns/client.go 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "net" 9 | "strings" 10 | "time" 11 | 12 | "github.com/miekg/dns" 13 | ) 14 | 15 | const ( 16 | mdnsAddr = "224.0.0.251" 17 | mdnsPort = 5353 18 | ) 19 | 20 | var ( 21 | mdnsUDPAddr = &net.UDPAddr{ 22 | IP: net.ParseIP(mdnsAddr), 23 | Port: mdnsPort, 24 | } 25 | searchDomain = "_airplay._tcp.local." 26 | ) 27 | 28 | type discovery struct { 29 | mconn *net.UDPConn 30 | uconn *net.UDPConn 31 | closed bool 32 | closedCh chan int 33 | } 34 | 35 | type entry struct { 36 | ipv4 net.IP 37 | port int 38 | hostName string 39 | domainName string 40 | textRecords map[string]string 41 | } 42 | 43 | type queryParam struct { 44 | timeout time.Duration 45 | maxCount int 46 | } 47 | 48 | func newDiscovery() (*discovery, error) { 49 | mconn, err := net.ListenMulticastUDP("udp4", nil, mdnsUDPAddr) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | uconn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | d := &discovery{ 60 | mconn: mconn, 61 | uconn: uconn, 62 | closed: false, 63 | closedCh: make(chan int), 64 | } 65 | return d, nil 66 | } 67 | 68 | func searchEntry(params *queryParam) []*entry { 69 | d, _ := newDiscovery() 70 | defer d.close() 71 | 72 | if params.timeout == 0 { 73 | params.timeout = 1 * time.Second 74 | } 75 | 76 | if params.maxCount <= 0 { 77 | params.maxCount = 5 78 | } 79 | 80 | return d.query(params) 81 | } 82 | 83 | func (d *discovery) query(params *queryParam) []*entry { 84 | // Send question 85 | m := new(dns.Msg) 86 | m.SetQuestion(searchDomain, dns.TypePTR) 87 | buf, _ := m.Pack() 88 | d.uconn.WriteToUDP(buf, mdnsUDPAddr) 89 | 90 | msgCh := make(chan *dns.Msg, 8) 91 | go d.receive(d.uconn, msgCh) 92 | go d.receive(d.mconn, msgCh) 93 | 94 | entries := []*entry{} 95 | finish := time.After(params.timeout) 96 | 97 | L: 98 | for { 99 | select { 100 | case response := <-msgCh: 101 | // Ignore question message 102 | if !response.MsgHdr.Response { 103 | continue 104 | } 105 | 106 | entry, err := d.parse(response) 107 | if err != nil { 108 | fmt.Println(err) 109 | continue 110 | } 111 | entries = append(entries, entry) 112 | 113 | if len(entries) >= params.maxCount { 114 | break L 115 | } 116 | case <-finish: 117 | break L 118 | } 119 | } 120 | 121 | return entries 122 | } 123 | 124 | func (d *discovery) close() { 125 | d.closed = true 126 | close(d.closedCh) 127 | d.uconn.Close() 128 | d.mconn.Close() 129 | } 130 | 131 | func (d *discovery) receive(l *net.UDPConn, ch chan *dns.Msg) { 132 | buf := make([]byte, dns.DefaultMsgSize) 133 | 134 | for !d.closed { 135 | n, _, err := l.ReadFromUDP(buf) 136 | if err != nil { 137 | // Ignore error that was occurred by Close() while blocked to read packet 138 | if !d.closed { 139 | log.Printf("airplay: [ERR] Failed to receive packet: %v", err) 140 | } 141 | continue 142 | } 143 | 144 | msg := new(dns.Msg) 145 | if err := msg.Unpack(buf[:n]); err != nil { 146 | log.Printf("airplay: [ERR] Failed to unpack packet: %v", err) 147 | continue 148 | } 149 | 150 | select { 151 | case ch <- msg: 152 | case <-d.closedCh: 153 | return 154 | } 155 | } 156 | } 157 | 158 | func (d *discovery) parse(resp *dns.Msg) (*entry, error) { 159 | entry := &entry{textRecords: make(map[string]string)} 160 | 161 | for _, answer := range resp.Answer { 162 | switch rr := answer.(type) { 163 | case *dns.PTR: 164 | entry.domainName = rr.Ptr 165 | } 166 | } 167 | 168 | if entry.domainName == "" { 169 | return nil, NewDNSResponseParseError("PTR", resp.Answer) 170 | } 171 | 172 | for _, extra := range resp.Extra { 173 | switch rr := extra.(type) { 174 | case *dns.SRV: 175 | if rr.Hdr.Name == entry.domainName { 176 | entry.hostName = rr.Target 177 | entry.port = int(rr.Port) 178 | } 179 | case *dns.TXT: 180 | if rr.Hdr.Name == entry.domainName { 181 | for _, txt := range rr.Txt { 182 | lines := strings.Split(txt, "=") 183 | entry.textRecords[lines[0]] = lines[1] 184 | } 185 | } 186 | } 187 | 188 | } 189 | 190 | if entry.hostName == "" { 191 | return nil, NewDNSResponseParseError("SRV", resp.Extra) 192 | } 193 | 194 | for _, extra := range resp.Extra { 195 | switch rr := extra.(type) { 196 | case *dns.A: 197 | if rr.Hdr.Name == entry.hostName { 198 | entry.ipv4 = rr.A 199 | } 200 | } 201 | } 202 | 203 | if entry.ipv4.String() == "" { 204 | return nil, NewDNSResponseParseError("A", resp.Extra) 205 | } 206 | 207 | return entry, nil 208 | } 209 | -------------------------------------------------------------------------------- /discovery_test.go: -------------------------------------------------------------------------------- 1 | package airplay 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "testing" 7 | 8 | "github.com/miekg/dns" 9 | ) 10 | 11 | func rr(s string) dns.RR { 12 | rr, err := dns.NewRR(s) 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | return rr 17 | } 18 | 19 | func TestParse(t *testing.T) { 20 | m := new(dns.Msg) 21 | m.Answer = []dns.RR{ 22 | rr("example.com. 10 IN PTR GongoTV.example.com."), 23 | } 24 | m.Extra = []dns.RR{ 25 | rr("GongoTV.local. 10 IN A 192.0.2.1"), 26 | rr("GongoTV.example.com. 120 IN SRV 0 0 7000 GongoTV.local."), 27 | rr("GongoTV.example.com. 120 IN TXT \"deviceid=00:00:00:00:00:00\" \"model=AppleTV2,1\""), 28 | } 29 | 30 | d, _ := newDiscovery() 31 | entry, err := d.parse(m) 32 | 33 | if err != nil { 34 | t.Fatalf("Unexpected error (%v)", err) 35 | } 36 | 37 | if !entry.ipv4.Equal(net.ParseIP("192.0.2.1")) { 38 | t.Errorf("Unexpected entry.ipv4 (%s)", entry.ipv4) 39 | } 40 | 41 | if entry.port != 7000 { 42 | t.Errorf("Unexpected entry.port (%d)", entry.port) 43 | } 44 | 45 | if len(entry.textRecords) != 2 { 46 | t.Errorf("Unexpected entry.textRecords (%v)", entry.textRecords) 47 | } 48 | } 49 | 50 | func TestParseErrorWithoutRequireRecords(t *testing.T) { 51 | m := new(dns.Msg) 52 | d, _ := newDiscovery() 53 | 54 | if _, err := d.parse(m); err == nil { 55 | t.Fatal("It should occurs [PTR not found] error") 56 | } 57 | 58 | m.Answer = []dns.RR{ 59 | rr("example.com. 10 IN PTR GongoTV.example.com."), 60 | } 61 | 62 | if _, err := d.parse(m); err == nil { 63 | t.Fatal("It should occurs [SRV not found] error") 64 | } 65 | 66 | m.Extra = []dns.RR{ 67 | rr("GongoTV.example.com. 120 IN SRV 0 0 7000 GongoTV.local."), 68 | } 69 | 70 | if _, err := d.parse(m); err == nil { 71 | t.Fatal("It should occurs [A not found] error") 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package airplay 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/miekg/dns" 7 | ) 8 | 9 | type DNSResponseParseError struct { 10 | Type string 11 | Records []dns.RR 12 | } 13 | 14 | func NewDNSResponseParseError(t string, r []dns.RR) *DNSResponseParseError { 15 | return &DNSResponseParseError{ 16 | Type: t, 17 | Records: r, 18 | } 19 | } 20 | 21 | func (e *DNSResponseParseError) Error() string { 22 | return fmt.Sprintf( 23 | "airplay: [ERR] Failed to get %s record:\n%s", 24 | e.Type, 25 | e.Records, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /example/devices/README.md: -------------------------------------------------------------------------------- 1 | # Example code for `airplay.Devices()` 2 | 3 | ## Install 4 | 5 | $ go get github.com/gongo/go-airplay/example/devices 6 | 7 | or 8 | 9 | $ cd $GOPATH/src/github.com/gongo/go-airplay/example/devices 10 | $ go build 11 | 12 | ## Run 13 | 14 | $ devices 15 | 16 | ``` 17 | +------------------+------------+------+ 18 | | NAME | IP ADDRESS | PORT | 19 | +------------------+------------+------+ 20 | | AppleTV.local. | 192.0.2.1 | 7000 | 21 | | AirServer.local. | 192.0.2.2 | 7000 | 22 | +------------------+------------+------+ 23 | 24 | * (AppleTV.local.) 25 | Model Name : AppleTV2,1 26 | MAC Address : FF:FF:FF:FF:FF:FF 27 | Server Version : 222.22 28 | Features : 0xFFFFFFF,0xF 29 | Password Required? : no 30 | 31 | * (AirServer.local.) 32 | Model Name : AppleTV3,2 33 | MAC Address : 00:00:00:00:00:00 34 | Server Version : 111.11 35 | Features : 0x10000000 36 | Password Required? : yes 37 | ``` 38 | -------------------------------------------------------------------------------- /example/devices/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/gongo/go-airplay" 9 | "github.com/olekukonko/tablewriter" 10 | ) 11 | 12 | func main() { 13 | table := tablewriter.NewWriter(os.Stdout) 14 | table.SetHeader([]string{"Name", "IP address", "Port"}) 15 | 16 | extraTemplate := ` 17 | * (%s) 18 | Model Name : %s 19 | MAC Address : %s 20 | Server Version : %s 21 | Features : %s 22 | Password Required? : %s 23 | ` 24 | extra := "" 25 | 26 | for _, device := range airplay.Devices() { 27 | table.Append([]string{ 28 | device.Name, 29 | device.Addr, 30 | strconv.Itoa(int(device.Port)), 31 | }) 32 | 33 | passwordRequiredFlag := "no" 34 | if device.Extra.IsPasswordRequired { 35 | passwordRequiredFlag = "yes" 36 | } 37 | 38 | extra += fmt.Sprintf( 39 | extraTemplate, 40 | device.Name, 41 | device.Extra.Model, 42 | device.Extra.MacAddress, 43 | device.Extra.ServerVersion, 44 | device.Extra.Features, 45 | passwordRequiredFlag, 46 | ) 47 | } 48 | 49 | table.Render() 50 | fmt.Println(extra) 51 | } 52 | -------------------------------------------------------------------------------- /example/player/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/gongo/go-airplay" 10 | ) 11 | 12 | var opts struct { 13 | mediaURL string 14 | startingPosition float64 15 | playTimeout int 16 | showHelpFlag bool 17 | } 18 | 19 | func init() { 20 | flag.StringVar(&opts.mediaURL, "i", "", "Input media URL") 21 | flag.Float64Var(&opts.startingPosition, "s", 0.0, "Starting position between 0 (0%) to 1 (100%)") 22 | flag.IntVar(&opts.playTimeout, "t", -1, "Timeout for play to end (sec)") 23 | flag.BoolVar(&opts.showHelpFlag, "h", false, "Show this message") 24 | flag.Parse() 25 | 26 | if opts.showHelpFlag { 27 | flag.Usage() 28 | os.Exit(0) 29 | } 30 | 31 | if opts.mediaURL == "" { 32 | log.Fatal("options: Missing media URL") 33 | } 34 | 35 | if opts.startingPosition < 0 || opts.startingPosition > 1 { 36 | log.Fatal("options: Starting position should between 0 to 1") 37 | } 38 | } 39 | 40 | func airplayClient() *airplay.Client { 41 | client, err := airplay.FirstClient() 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | return client 46 | } 47 | 48 | func playToEnd() { 49 | client := airplayClient() 50 | ch := client.PlayAt(opts.mediaURL, opts.startingPosition) 51 | <-ch 52 | } 53 | 54 | func playUntilTimeoutOrEnd() { 55 | client := airplayClient() 56 | ch := client.PlayAt(opts.mediaURL, opts.startingPosition) 57 | timeout := time.After(time.Duration(opts.playTimeout) * time.Second) 58 | 59 | select { 60 | case <-timeout: 61 | client.Stop() 62 | case <-ch: 63 | } 64 | } 65 | 66 | func main() { 67 | if opts.playTimeout > 0 { 68 | playUntilTimeoutOrEnd() 69 | } else { 70 | playToEnd() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /example/seeker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | 8 | "github.com/gongo/go-airplay" 9 | ) 10 | 11 | var opts struct { 12 | position float64 13 | showHelpFlag bool 14 | } 15 | 16 | func init() { 17 | flag.Float64Var(&opts.position, "p", 0.0, "Number of seconds to move (second)") 18 | flag.BoolVar(&opts.showHelpFlag, "h", false, "Show this message") 19 | flag.Parse() 20 | 21 | if opts.showHelpFlag { 22 | flag.Usage() 23 | os.Exit(0) 24 | } 25 | 26 | if opts.position < 0 { 27 | log.Fatal("options: position should not negative") 28 | } 29 | } 30 | 31 | func main() { 32 | client, _ := airplay.NewClient() 33 | client.Scrub(opts.position) 34 | } 35 | -------------------------------------------------------------------------------- /example/slideshow/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "math/rand" 7 | "os" 8 | "time" 9 | 10 | "github.com/gongo/go-airplay" 11 | ) 12 | 13 | var opts struct { 14 | imagePath string 15 | showHelpFlag bool 16 | } 17 | 18 | func init() { 19 | flag.StringVar(&opts.imagePath, "i", "", "Input image path (local or remote)") 20 | flag.BoolVar(&opts.showHelpFlag, "h", false, "Show this message") 21 | flag.Parse() 22 | 23 | if opts.showHelpFlag { 24 | flag.Usage() 25 | os.Exit(0) 26 | } 27 | 28 | if opts.imagePath == "" { 29 | flag.Usage() 30 | log.Fatal("options: Missing image path") 31 | } 32 | } 33 | 34 | func main() { 35 | client, _ := airplay.NewClient() 36 | rand.Seed(time.Now().UnixNano()) 37 | 38 | transitions := []airplay.SlideTransition{ 39 | airplay.SlideRight, 40 | airplay.SlideLeft, 41 | } 42 | 43 | timeout := time.After(10 * time.Second) 44 | interval := time.Tick(time.Second) 45 | 46 | for { 47 | select { 48 | case <-timeout: 49 | return 50 | case <-interval: 51 | index := rand.Intn(len(transitions)) 52 | client.PhotoWithSlide(opts.imagePath, transitions[index]) 53 | } 54 | } 55 | } 56 | --------------------------------------------------------------------------------