├── .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 | [](http://godoc.org/github.com/gongo/go-airplay)
5 | [](https://travis-ci.org/gongo/go-airplay)
6 | [](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 |
--------------------------------------------------------------------------------