├── .gcloudignore ├── .gitignore ├── LICENSE ├── README.md ├── apollo_server_functions.go ├── go.mod └── go.sum /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | 11 | # Ignore module files since we're using "go mod vendor". 12 | go.mod 13 | go.sum 14 | 15 | # If you would like to upload your .git directory, .gitignore file or files 16 | # from your .gitignore file, remove the corresponding line 17 | # below: 18 | .git 19 | .gitignore 20 | 21 | node_modules 22 | #!include:.gitignore 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | #vendor/ 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Leonhard Gruenschloss 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # open-apollo server component 2 | 3 | This is an implementation of the server component for the 4 | [Apollo](https://github.com/KhaosT/open-apollo) Spotify player 5 | that implements streaming on the Apple Watch. 6 | 7 | Special thanks to @KhaosT for making this player available as open source and 8 | to @rafaelmaeuer for pointing me in the right direction for the server 9 | implementation. 10 | 11 | ## Prerequisites 12 | 13 | To run this, you'll need to have 14 | [Google Cloud Functions](https://cloud.google.com/functions) 15 | configured. 16 | 17 | You'll need to have a Spotify app registered at 18 | https://developer.spotify.com/dashboard/applications. 19 | 20 | Enter `open-apollo://callback` under "Redirect URIs" and `app.awas.Apollo` 21 | under "Bundle IDs". 22 | 23 | Note your client ID and secret, you'll need it below. 24 | 25 | ## Installing the server endpoints 26 | 27 | Set the following environment variables: `SPOTIFY_CLIENT_ID`, 28 | `SPOTIFY_CLIENT_SECRET`. 29 | 30 | Select a nearby region to keep latency low, replacing `$REGION` below: 31 | 32 | ``` 33 | gcloud config set functions/region $REGION 34 | ``` 35 | 36 | The `replace` statement in the `go.mod` file doesn't seem to work with the way 37 | Cloud Functions are compiled for deployment, therefore run the following to 38 | create a `vendor` directory: 39 | 40 | ``` 41 | go mod vendor 42 | ``` 43 | 44 | Finally, deploy the various endpoints: 45 | 46 | ``` 47 | # /token 48 | gcloud functions deploy token --entry-point Token --runtime go113 --trigger-http --set-env-vars SPOTIFY_CLIENT_ID=$SPOTIFY_CLIENT_ID,SPOTIFY_CLIENT_SECRET=$SPOTIFY_CLIENT_SECRET 49 | 50 | # /refresh 51 | gcloud functions deploy refresh --entry-point Refresh --runtime go113 --trigger-http --set-env-vars SPOTIFY_CLIENT_ID=$SPOTIFY_CLIENT_ID,SPOTIFY_CLIENT_SECRET=$SPOTIFY_CLIENT_SECRET 52 | 53 | # /track 54 | gcloud functions deploy track --entry-point Track --runtime go113 --trigger-http 55 | 56 | # /tracks 57 | gcloud functions deploy tracks --entry-point Tracks --runtime go113 --trigger-http 58 | 59 | # /storage-resolve 60 | gcloud functions deploy storage-resolve --entry-point StorageResolve --runtime go113 --trigger-http 61 | ``` 62 | 63 | ## Configuring open-apollo 64 | 65 | Clone the [open-apollo](https://github.com/KhaosT/open-apollo) repository. 66 | 67 | Copy the base URL for your endpoints 68 | (`https://$REGION-$PROJECT.cloudfunctions.net/`) to the 69 | `open-apollo/Apollo/Configuration/DefaultServiceConfiguration.swift` file, for 70 | both `YOUR_SERVICE_URL` and `YOUR_TRACK_SERVICE_URL`. 71 | 72 | In the `open-apollo/Apollo/Configuration/SpotifyAuthorizationContext.swift` 73 | file, enter your client ID as `YOUR_CLIENT_ID`, `open-apollo` as the 74 | `callbackURLScheme`, and `open-apollo://callback` as the `redirect_uri`. 75 | 76 | In your Apollo Xcode project, in the `Info` settings for the `Apollo` target, 77 | make sure to add `open-apollo` as "URL Schemes" in the "URL Types" section. 78 | 79 | Compile and run the Apollo app. If you have trouble installing the Apollo Watch 80 | app through the iOS Watch app, try running the `Watch` target directly from 81 | Xcode. 82 | 83 | If you get any errors, the Cloud Function logs might contain some clues. 84 | -------------------------------------------------------------------------------- /apollo_server_functions.go: -------------------------------------------------------------------------------- 1 | package apollo_server_functions 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/ecdsa" 8 | "crypto/elliptic" 9 | "crypto/rand" 10 | "crypto/sha256" 11 | b64 "encoding/base64" 12 | "encoding/binary" 13 | "encoding/json" 14 | "errors" 15 | "fmt" 16 | "io/ioutil" 17 | "log" 18 | "net/http" 19 | "net/url" 20 | "os" 21 | 22 | "github.com/librespot-org/librespot-golang/Spotify" 23 | "github.com/librespot-org/librespot-golang/librespot/core" 24 | "github.com/librespot-org/librespot-golang/librespot/utils" 25 | ) 26 | 27 | type ApiTokenResult struct { 28 | AccessToken string `json:"access_token"` 29 | RefreshToken string `json:"refresh_token"` 30 | } 31 | 32 | func apiToken(code string) (*ApiTokenResult, error) { 33 | clientId := os.Getenv("SPOTIFY_CLIENT_ID") 34 | clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") 35 | 36 | if clientId == "" || clientSecret == "" { 37 | return nil, errors.New("No client ID / secret set") 38 | } 39 | 40 | resp, err := http.PostForm("https://accounts.spotify.com/api/token", 41 | url.Values{ 42 | "grant_type": {"authorization_code"}, 43 | "code": {code}, 44 | "redirect_uri": {"open-apollo://callback"}, 45 | "client_id": {clientId}, 46 | "client_secret": {clientSecret}, 47 | }) 48 | 49 | if err != nil { 50 | return nil, err 51 | } 52 | defer resp.Body.Close() 53 | 54 | var res ApiTokenResult 55 | if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 56 | return nil, err 57 | } 58 | 59 | return &res, nil 60 | } 61 | 62 | func Token(w http.ResponseWriter, r *http.Request) { 63 | body, err := ioutil.ReadAll(r.Body) 64 | if err != nil { 65 | http.Error(w, "Can't read body", http.StatusBadRequest) 66 | return 67 | } 68 | log.Printf("request: %s", body) 69 | 70 | var req struct { 71 | Code string `json:"code"` 72 | } 73 | 74 | if err := json.NewDecoder(bytes.NewReader(body)).Decode(&req); err != nil { 75 | http.Error(w, "Can't decode request", http.StatusInternalServerError) 76 | log.Printf("Decoding error: %v", err) 77 | return 78 | } 79 | 80 | apiToken, err := apiToken(req.Code) 81 | if err != nil { 82 | http.Error(w, "Can't fetch token", http.StatusInternalServerError) 83 | log.Printf("apiToken error: %v", err) 84 | return 85 | } 86 | 87 | res := struct { 88 | AccessToken string `json:"access_token"` 89 | RefreshToken string `json:"refresh_token"` 90 | TokenType string `json:"token_type"` 91 | ExpiresIn int `json:"expires_in"` 92 | }{ 93 | apiToken.AccessToken, 94 | apiToken.RefreshToken, 95 | "Bearer", 96 | 3600, 97 | } 98 | 99 | buf := new(bytes.Buffer) 100 | json.NewEncoder(buf).Encode(res) 101 | fmt.Fprint(w, buf) 102 | 103 | log.Printf("response: %s", buf) 104 | } 105 | 106 | func apiRefresh(refreshToken string) (*ApiTokenResult, error) { 107 | clientId := os.Getenv("SPOTIFY_CLIENT_ID") 108 | clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") 109 | 110 | if clientId == "" || clientSecret == "" { 111 | return nil, errors.New("No client ID / secret set") 112 | } 113 | 114 | resp, err := http.PostForm("https://accounts.spotify.com/api/token", 115 | url.Values{ 116 | "grant_type": {"refresh_token"}, 117 | "refresh_token": {refreshToken}, 118 | "client_id": {clientId}, 119 | "client_secret": {clientSecret}, 120 | }) 121 | 122 | if err != nil { 123 | return nil, err 124 | } 125 | defer resp.Body.Close() 126 | 127 | var res ApiTokenResult 128 | if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 129 | return nil, err 130 | } 131 | 132 | // Only return changed new refresh tokens. 133 | if res.RefreshToken == refreshToken { 134 | res.RefreshToken = "" 135 | } 136 | 137 | return &res, nil 138 | } 139 | 140 | func Refresh(w http.ResponseWriter, r *http.Request) { 141 | body, err := ioutil.ReadAll(r.Body) 142 | if err != nil { 143 | http.Error(w, "Can't read body", http.StatusBadRequest) 144 | return 145 | } 146 | log.Printf("request: %s", body) 147 | 148 | var req struct { 149 | RefreshToken string `json:"refresh_token"` 150 | } 151 | 152 | if err := json.NewDecoder(bytes.NewReader(body)).Decode(&req); err != nil { 153 | http.Error(w, "Can't decode request", http.StatusInternalServerError) 154 | log.Printf("Decoding error: %v", err) 155 | return 156 | } 157 | 158 | apiToken, err := apiRefresh(req.RefreshToken) 159 | if err != nil { 160 | http.Error(w, "Can't refresh token", http.StatusInternalServerError) 161 | log.Printf("apiRefresh error: %v", err) 162 | return 163 | } 164 | 165 | res := struct { 166 | AccessToken string `json:"access_token"` 167 | RefreshToken string `json:"refresh_token,omitempty"` 168 | TokenType string `json:"token_type"` 169 | ExpiresIn int `json:"expires_in"` 170 | }{ 171 | apiToken.AccessToken, 172 | apiToken.RefreshToken, 173 | "Bearer", 174 | 3600, 175 | } 176 | 177 | buf := new(bytes.Buffer) 178 | json.NewEncoder(buf).Encode(res) 179 | fmt.Fprint(w, buf) 180 | 181 | log.Printf("response: %s", buf) 182 | } 183 | 184 | // From https://gist.github.com/KhaosT/73d56a3cd0496aefaa74c8e320602547. 185 | func encryptTrackKey(otherPublicKeyBytes []byte, trackKey []byte) ([]byte, error) { 186 | curve := elliptic.P256() 187 | 188 | ephemeraPrivateKey, err := ecdsa.GenerateKey(curve, rand.Reader) 189 | 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | ephemeralPublicKey := elliptic.Marshal(curve, ephemeraPrivateKey.X, ephemeraPrivateKey.Y) 195 | 196 | otherPublicKeyX, otherPublicKeyY := elliptic.Unmarshal(curve, otherPublicKeyBytes) 197 | 198 | // ECDH 199 | x, _ := curve.ScalarMult(otherPublicKeyX, otherPublicKeyY, ephemeraPrivateKey.D.Bytes()) 200 | shared_key := x.Bytes() 201 | 202 | // X963 KDF 203 | length := 32 204 | output := make([]byte, 0) 205 | outlen := 0 206 | counter := uint32(1) 207 | 208 | for outlen < length { 209 | h := sha256.New() 210 | h.Write(shared_key) // Key Material: ECDH Key 211 | 212 | counterBuf := make([]byte, 4) 213 | binary.BigEndian.PutUint32(counterBuf, counter) 214 | h.Write(counterBuf) 215 | 216 | h.Write(ephemeralPublicKey) // Shared Info: Our public key 217 | 218 | output = h.Sum(output) 219 | outlen += h.Size() 220 | counter += 1 221 | } 222 | 223 | // Key 224 | encryptionKey := output[0:16] 225 | iv := output[16:] 226 | 227 | // AES 228 | block, _ := aes.NewCipher(encryptionKey) 229 | aesgcm, _ := cipher.NewGCMWithNonceSize(block, 16) 230 | 231 | ct := aesgcm.Seal(nil, iv, trackKey, nil) 232 | 233 | return append(ephemeralPublicKey, ct...), nil 234 | } 235 | 236 | func selectFile(track *Spotify.Track) *Spotify.AudioFile { 237 | // Prefer 160 kbps, to reduce bandwidth. Fall back to 320 / 96 kbps. 238 | var selectedFile *Spotify.AudioFile 239 | var selectedFormat Spotify.AudioFile_Format 240 | for _, file := range track.GetFile() { 241 | format := file.GetFormat() 242 | if (format == Spotify.AudioFile_OGG_VORBIS_96 && selectedFile == nil) || 243 | (format == Spotify.AudioFile_OGG_VORBIS_320 && (selectedFile == nil || selectedFormat == Spotify.AudioFile_OGG_VORBIS_96)) || 244 | format == Spotify.AudioFile_OGG_VORBIS_160 { 245 | selectedFormat = format 246 | selectedFile = file 247 | } 248 | } 249 | return selectedFile 250 | } 251 | 252 | type LoadTrackResult struct { 253 | FileId string // OGG file, base 62 encoded. 254 | TrackKey string // Encrypted and base 64 encoded. 255 | } 256 | 257 | func loadTrack(session *core.Session, trackId string, publicKey []byte) (*LoadTrackResult, error) { 258 | track, err := session.Mercury().GetTrack(utils.Base62ToHex(trackId)) 259 | if err != nil { 260 | return nil, err 261 | } 262 | 263 | selectedFile := selectFile(track) 264 | 265 | // Check alternatives if the track doesn't have any suitable files. 266 | if selectedFile == nil { 267 | for _, alt := range track.GetAlternative() { 268 | selectedFile = selectFile(alt) 269 | if selectedFile != nil { 270 | break 271 | } 272 | } 273 | } 274 | 275 | if selectedFile == nil { 276 | return nil, errors.New("Can't find OGG file") 277 | } 278 | 279 | trackKey, err := session.Player().LoadTrackKey(track.GetGid(), selectedFile.FileId) 280 | if err != nil { 281 | return nil, err 282 | } 283 | 284 | enc, err := encryptTrackKey(publicKey, trackKey) 285 | if err != nil { 286 | return nil, err 287 | } 288 | 289 | return &LoadTrackResult{ 290 | utils.ConvertTo62(selectedFile.FileId), 291 | b64.StdEncoding.EncodeToString(enc)}, nil 292 | } 293 | 294 | type TrackResponse struct { 295 | TrackId string `json:"track_id"` 296 | FileId string `json:"file_id"` 297 | TrackKey string `json:"track_key"` 298 | } 299 | 300 | func Track(w http.ResponseWriter, r *http.Request) { 301 | body, err := ioutil.ReadAll(r.Body) 302 | if err != nil { 303 | http.Error(w, "Can't read body", http.StatusBadRequest) 304 | return 305 | } 306 | log.Printf("request: %s", body) 307 | 308 | var req struct { 309 | TrackId string `json:"track_id"` 310 | Token string `json:"token"` 311 | PublicKey string `json:"public_key"` 312 | } 313 | 314 | if err := json.NewDecoder(bytes.NewReader(body)).Decode(&req); err != nil { 315 | http.Error(w, "Can't decode request", http.StatusInternalServerError) 316 | log.Printf("Decoding error: %v", err) 317 | return 318 | } 319 | 320 | publicKey, err := b64.StdEncoding.DecodeString(req.PublicKey) 321 | if err != nil { 322 | http.Error(w, "Can't decode public key", http.StatusInternalServerError) 323 | log.Printf("Error decoding public key: %v", err) 324 | return 325 | } 326 | 327 | session, err := core.LoginOAuthToken(req.Token, "librespot") 328 | if err != nil { 329 | http.Error(w, "Can't initialize session", http.StatusInternalServerError) 330 | log.Printf("Session login error: %v", err) 331 | return 332 | } 333 | 334 | track, err := loadTrack(session, req.TrackId, publicKey) 335 | if err != nil { 336 | http.Error(w, "Can't find track", http.StatusNotFound) 337 | log.Printf("loadTrack error for track ID %s: %v", req.TrackId, err) 338 | return 339 | } 340 | 341 | res := TrackResponse{ 342 | req.TrackId, 343 | track.FileId, 344 | track.TrackKey, 345 | } 346 | 347 | buf := new(bytes.Buffer) 348 | json.NewEncoder(buf).Encode(res) 349 | fmt.Fprint(w, buf) 350 | 351 | log.Printf("response: %s", buf) 352 | } 353 | 354 | func Tracks(w http.ResponseWriter, r *http.Request) { 355 | body, err := ioutil.ReadAll(r.Body) 356 | if err != nil { 357 | http.Error(w, "Can't read body", http.StatusBadRequest) 358 | return 359 | } 360 | log.Printf("request: %s", body) 361 | 362 | var req struct { 363 | TrackIds []string `json:"track_ids"` 364 | Token string `json:"token"` 365 | PublicKey string `json:"public_key"` 366 | } 367 | 368 | if err := json.NewDecoder(bytes.NewReader(body)).Decode(&req); err != nil { 369 | http.Error(w, "Can't decode request", http.StatusInternalServerError) 370 | log.Printf("Decoding error: %v", err) 371 | return 372 | } 373 | 374 | publicKey, err := b64.StdEncoding.DecodeString(req.PublicKey) 375 | if err != nil { 376 | http.Error(w, "Can't decode public key", http.StatusInternalServerError) 377 | log.Printf("Error decoding public key: %v", err) 378 | return 379 | } 380 | 381 | session, err := core.LoginOAuthToken(req.Token, "librespot") 382 | if err != nil { 383 | http.Error(w, "Can't initialize session", http.StatusInternalServerError) 384 | log.Printf("Session login error: %v", err) 385 | return 386 | } 387 | 388 | var res struct { 389 | Tracks []TrackResponse `json:"tracks"` 390 | } 391 | 392 | for _, trackId := range req.TrackIds { 393 | track, err := loadTrack(session, trackId, publicKey) 394 | 395 | if err != nil { 396 | log.Printf("loadTrack error for track ID %s: %v", trackId, err) 397 | } else { 398 | res.Tracks = append(res.Tracks, TrackResponse{trackId, track.FileId, track.TrackKey}) 399 | } 400 | } 401 | 402 | buf := new(bytes.Buffer) 403 | json.NewEncoder(buf).Encode(res) 404 | fmt.Fprint(w, buf) 405 | 406 | log.Printf("response: %s", buf) 407 | } 408 | 409 | func trackUrl(accessToken string, fileId string) (*string, error) { 410 | // Convert file ID from base 62 to hex. 411 | url := "https://api.spotify.com/v1/storage-resolve/files/audio/interactive/" + utils.Base62ToHex(fileId) + "?alt=json" 412 | req, err := http.NewRequest("GET", url, nil) 413 | if err != nil { 414 | return nil, err 415 | } 416 | 417 | req.Header.Add("Authorization", "Bearer "+accessToken) 418 | client := &http.Client{} 419 | resp, err := client.Do(req) 420 | if err != nil { 421 | return nil, err 422 | } 423 | defer resp.Body.Close() 424 | 425 | var res struct { 426 | CdnUrl []string `json:"cdnurl"` 427 | } 428 | 429 | if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 430 | return nil, err 431 | } 432 | 433 | if len(res.CdnUrl) > 0 { 434 | return &res.CdnUrl[0], nil 435 | } 436 | 437 | return nil, errors.New("Empty URL list") 438 | } 439 | 440 | func StorageResolve(w http.ResponseWriter, r *http.Request) { 441 | body, err := ioutil.ReadAll(r.Body) 442 | if err != nil { 443 | http.Error(w, "Can't read body", http.StatusBadRequest) 444 | return 445 | } 446 | log.Printf("request: %s", body) 447 | 448 | var req struct { 449 | AccessToken string `json:"access_token"` 450 | FileId string `json:"file_id"` 451 | } 452 | 453 | if err := json.NewDecoder(bytes.NewReader(body)).Decode(&req); err != nil { 454 | http.Error(w, "Can't decode request", http.StatusInternalServerError) 455 | log.Printf("Decoding error: %v", err) 456 | return 457 | } 458 | 459 | url, err := trackUrl(req.AccessToken, req.FileId) 460 | if err != nil { 461 | http.Error(w, "File not found", http.StatusNotFound) 462 | log.Printf("trackUrl error for file ID %s: %v", req.FileId, err) 463 | return 464 | } 465 | 466 | res := struct { 467 | Url string `json:"url"` 468 | }{ 469 | *url, 470 | } 471 | 472 | buf := new(bytes.Buffer) 473 | json.NewEncoder(buf).Encode(res) 474 | fmt.Fprint(w, buf) 475 | 476 | log.Printf("response: %s", buf) 477 | } 478 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lgruen/open-apollo-server 2 | 3 | require github.com/librespot-org/librespot-golang v0.0.0-20190422094710-916b535e1393 4 | 5 | go 1.13 6 | 7 | replace github.com/librespot-org/librespot-golang v0.0.0-20190422094710-916b535e1393 => github.com/lgruen/librespot-golang v0.0.0-20200214104446-8904ad98fcc2 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/badfortrains/mdns v0.0.0-20160325001438-447166384f51 h1:b6+GdpGhuzxPETrVLwY77iq0L/BqLpcPRmaLNW+SoRY= 2 | github.com/badfortrains/mdns v0.0.0-20160325001438-447166384f51/go.mod h1:qHRkxMBkkpAWD2poeBYQt6O5IS4dE6w1Cr4Z8Q3feXI= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 6 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 7 | github.com/lgruen/librespot-golang v0.0.0-20200214104446-8904ad98fcc2 h1:KGaBN4Kd9BnDW8a9G1d5qIZlel86VHpw1DyIMeiCk7g= 8 | github.com/lgruen/librespot-golang v0.0.0-20200214104446-8904ad98fcc2/go.mod h1:LU1xGywRZvrii+vATCrvT+chKWKv/oAKJG56zbEu4G4= 9 | github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM= 10 | github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 14 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 15 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 16 | github.com/xlab/portaudio-go v0.0.0-20170905165025-132d041879db h1:sSIQlvfIWUHLDhEWUL2K2CeYv9CDksC00VxuxPUe4lw= 17 | github.com/xlab/portaudio-go v0.0.0-20170905165025-132d041879db/go.mod h1:r57mRacDQMS6Fz8ubv1nE8zZ0DbQ/sY0NkCmdxeUXmY= 18 | github.com/xlab/vorbis-go v0.0.0-20190125051917-087364aef51d h1:WXSpRkRlV+KXRqO0UL6ScsRUnvApBAjp0MdggYrgZZ0= 19 | github.com/xlab/vorbis-go v0.0.0-20190125051917-087364aef51d/go.mod h1:AMqfx3jFwPqem3u8mF2lsRodZs30jG/Mag5HZ3mB3sA= 20 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 21 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= 22 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 23 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 24 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 25 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 26 | golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7MvYXnkV9/iQNo1lX6g= 27 | golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 28 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= 29 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 30 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 31 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 32 | golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe h1:6fAMxZRR6sl1Uq8U61gxU+kPTs2tR8uOySCbBP7BN/M= 33 | golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 34 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 35 | golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 36 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 37 | --------------------------------------------------------------------------------