difm.go
1 package difm 2 3 import ( 4 "bufio" 5 "bytes" 6 "encoding/json" 7 "fmt" 8 "io" 9 "log" 10 "net/http" 11 "net/http/cookiejar" 12 "net/url" 13 "os" 14 "regexp" 15 "strconv" 16 "strings" 17 "time" 18 19 "github.com/acaloiaro/di-tui/context" 20 "github.com/acaloiaro/di-tui/player" 21 22 "github.com/acaloiaro/di-tui/components" 23 "github.com/acaloiaro/di-tui/config" 24 "github.com/bradfitz/iter" 25 ini "gopkg.in/ini.v1" 26 ) 27 28 type ApplicationMetadata struct { 29 User struct { 30 ID int64 `json:"id"` 31 AudioToken string `json:"audio_token"` 32 SessionKey string `json:"session_key"` 33 } `json:"user"` 34 CsrfToken string 35 } 36 37 type authResponse struct { 38 ListenKey string `json:"listen_key"` 39 } 40 41 // Authenticate authenticates to the di.fm API with a username and password 42 // 43 // Note: There is a more API-friendly way of authenticating to the audioaddict API. However, because only the "web 44 // player" allows interactions such as adding/removing favorite channels, we're obligated to authenticate in the way 45 // that the web player authenticates. 46 47 // login workflow 48 // 1. GET www.di.fm/login to get the CSRF token 49 // 2. POST www.di.fm/login (with CSRF token and other appropriate headers) 50 // 3. GET www.di.fm/ to retrieve two key pieces of information 51 // - an "audio_token" 52 // - a "session_key" 53 // 54 // Both of which are required to perform advanced player features such as adding/removing favorites. 55 // 56 // One added benefit of logging in this way is that di-tui may use the audio token to stream "on-demand" audio in the 57 // future, which would allow the player to buffer content. Currently, no buffering is performed because di-tui uses the 58 // streaming API. 59 // 60 // However, until a developer-friendly, go-native AAC decoder is available, this player will 61 // continue using the MP3 "streaming" API. 62 func Authenticate(ctx *context.AppContext, username, password string) (token string) { 63 jar, err := cookiejar.New(nil) 64 if err != nil { 65 log.Fatalf("Got error while creating cookie jar %s", err.Error()) 66 } 67 client := &http.Client{Jar: jar} 68 69 // 1. GET www.di.fm/login to get the CSRF token 70 meta, _ := getApplicationMetadata(ctx, client) 71 authURL := "https://www.di.fm/login" 72 data := url.Values{} 73 data.Set("member_session[username]", username) 74 data.Set("member_session[password]", password) 75 76 encodedData := data.Encode() 77 78 // 2. POST www.di.fm/login (with CSRF token and other appropriate headers) 79 req, _ := http.NewRequest("POST", authURL, strings.NewReader(encodedData)) 80 req.Header.Add("Content-Length", strconv.Itoa(len(encodedData))) 81 req.Header.Add("Origin", "https://www.di.fm") 82 req.Header.Add("Referrer", "https://www.di.fm") 83 req.Header.Add("Sec-Fetch-Mode", "cors") 84 req.Header.Add("Sec-Fetch-Dest", "empty") 85 req.Header.Add("Sec-Fetch-Site", "same-origin") 86 req.Header.Add("X-Requested-With", "XMLHttpRequest") 87 req.Header.Add("X-CSRF-Token", meta.CsrfToken) 88 89 resp, err := client.Do(req) 90 if err != nil { 91 log.Fatal("authentication failed", err) 92 } 93 defer resp.Body.Close() 94 95 var res authResponse 96 body, err := io.ReadAll(resp.Body) 97 if err != nil || resp.StatusCode != 200 { 98 fmt.Println("Unable to authenticate to di.fm. Status code:", resp.StatusCode) 99 os.Exit(1) 100 } 101 102 json.Unmarshal(body, &res) 103 config.SaveListenToken(res.ListenKey) 104 105 // 3. GET www.di.fm/ to retrieve two key pieces of information 106 meta, err = getApplicationMetadata(ctx, client) 107 if err != nil { 108 fmt.Println("Unable to fetch audio token and session key") 109 os.Exit(1) 110 } 111 112 config.SaveAudioToken(meta.User.AudioToken) 113 config.SaveSessionKey(meta.User.SessionKey) 114 config.SaveUserID(meta.User.ID) 115 116 return 117 } 118 119 // GetApplicationMetadata fetches the application's metadata (the di.fm player application) from www.di.fm 120 func getApplicationMetadata(ctx *context.AppContext, client *http.Client) (appMeta ApplicationMetadata, err error) { 121 var req *http.Request 122 req, err = http.NewRequest("GET", "https://www.di.fm", nil) 123 if err != nil { 124 return 125 } 126 127 req.Header.Add("Sec-Fetch-Mode", "cors") 128 req.Header.Add("Sec-Fetch-Dest", "empty") 129 req.Header.Add("Sec-Fetch-Site", "same-origin") 130 131 var resp *http.Response 132 resp, err = client.Do(req) 133 if err != nil || resp.StatusCode != 200 { 134 return 135 } 136 defer resp.Body.Close() 137 138 var body []byte 139 body, err = io.ReadAll(resp.Body) 140 if err != nil || resp.StatusCode != 200 { 141 return 142 } 143 144 bodyStr := string(body) 145 re := regexp.MustCompile(`.*di\.app\.start\((.*)\);.*`) 146 matches := re.FindStringSubmatch(bodyStr) 147 if len(matches) > 0 { 148 appMeta = ApplicationMetadata{} 149 err = json.Unmarshal([]byte(matches[1]), &appMeta) 150 if err != nil { 151 return 152 } 153 } 154 155 re = regexp.MustCompile(`.*meta name="csrf-token" content="(.*?)"/>`) 156 matches = re.FindStringSubmatch(bodyStr) 157 if len(matches) > 0 { 158 appMeta.CsrfToken = matches[1] 159 } 160 161 return 162 } 163 164 // GetStreamURL extracts a playlist's stream URL from raw INI bytes (pls file) 165 func GetStreamURL(data []byte, ctx *context.AppContext) (streamURL string, ok bool) { 166 cfg, err := ini.Load(data) 167 if err != nil { 168 ctx.SetStatusMessage("Unable to fetch channel playlist file.") 169 ok = false 170 return 171 } 172 173 streamURL = cfg.Section("playlist").Key("File1").String() 174 ok = streamURL != "" 175 176 return 177 } 178 179 // GetCurrentlyPlaying fetches the list of all currently playing tracks site-side 180 func GetCurrentlyPlaying(ctx *context.AppContext) (currentlyPlaying components.CurrentlyPlaying) { 181 client := &http.Client{} 182 req, _ := http.NewRequest("GET", "https://api.audioaddict.com/v1/di/currently_playing", nil) 183 resp, err := client.Do(req) 184 if err != nil || resp.StatusCode != 200 { 185 ctx.SetStatusMessage("Unable to fetch currently playing track info") 186 return 187 } 188 defer resp.Body.Close() 189 190 body, err := io.ReadAll(resp.Body) 191 if err != nil || resp.StatusCode != 200 { 192 ctx.SetStatusMessage("Unable to fetch currently playing track info.") 193 194 return 195 } 196 197 var currentlyPlayingStations []components.CurrentlyPlaying 198 json.Unmarshal(body, ¤tlyPlayingStations) 199 200 for _, cp := range currentlyPlayingStations { 201 if cp.ChannelID == ctx.CurrentChannel.ID { 202 return cp 203 } 204 } 205 206 return 207 } 208 209 // ListChannels lists all premium MP3 channels 210 func ListChannels(ctx *context.AppContext) (channels []components.ChannelItem) { 211 client := &http.Client{} 212 req, _ := http.NewRequest("GET", "http://listen.di.fm/premium_high", nil) 213 resp, err := client.Do(req) 214 if err != nil || resp.StatusCode != 200 { 215 ctx.SetStatusMessage("Unable to fetch the list of channels") 216 return 217 } 218 defer resp.Body.Close() 219 220 body, err := io.ReadAll(resp.Body) 221 if err != nil { 222 ctx.SetStatusMessage("Unable to fetch the list of channels") 223 return 224 } 225 226 err = json.Unmarshal(body, &channels) 227 if err != nil { 228 ctx.SetStatusMessage("Unable to fetch the list of channels") 229 return 230 } 231 232 return 233 } 234 235 // ListFavorites lists a user's favorite channels 236 func ListFavorites(ctx *context.AppContext) (favorites []components.FavoriteItem) { 237 client := &http.Client{} 238 url := fmt.Sprintf("%s?%s", "http://listen.di.fm/premium_high/favorites.pls", ctx.DifmToken) 239 req, _ := http.NewRequest("GET", url, nil) 240 resp, err := client.Do(req) 241 if err != nil || resp.StatusCode != 200 { 242 ctx.SetStatusMessage("There was a problem fetching your favorites") 243 return 244 } 245 defer resp.Body.Close() 246 247 body, _ := io.ReadAll(resp.Body) 248 cfg, err := ini.Load(body) 249 if err != nil { 250 ctx.SetStatusMessage("There was a problem fetching your favorites") 251 return 252 } 253 254 sec := "playlist" 255 numEntries := cfg.Section(sec).Key("NumberOfEntries").MustInt(0) 256 for i := range iter.N(numEntries) { 257 // di.fm's PLS keys begin at 1 258 k := i + 1 259 favorites = append(favorites, components.FavoriteItem{ 260 Name: cfg.Section(sec).Key(fmt.Sprintf("Title%d", k)).String(), 261 PlaylistURL: cfg.Section(sec).Key(fmt.Sprintf("File%d", k)).String(), 262 }) 263 } 264 265 return 266 } 267 268 // ToggleFavorite adds/removes the currentlly selected channel to/from the user's favorites 269 func ToggleFavorite(ctx *context.AppContext) { 270 if config.GetSessionKey() == "" { 271 ctx.SetStatusMessage("Unfortunately you must log in to di-tui with a username and password to change favorites.") 272 return 273 } 274 275 client := &http.Client{} 276 url := fmt.Sprintf("https://api.audioaddict.com/v1/di/members/%d/favorites/channel/%d", config.GetUserID(), ctx.HighlightedChannel.ID) 277 278 requestMethod := "POST" 279 focusedView := ctx.View.App.GetFocus() 280 // if the user is currently viewing favorites, then the request is to remove the channel from the favorite list 281 if focusedView == ctx.View.FavoriteList { 282 requestMethod = "DELETE" 283 } 284 285 url = fmt.Sprintf("%s?audio_token=%s", url, config.GetAudioToken()) 286 287 var jsonStr = []byte(fmt.Sprintf(`{"id": %d}`, ctx.HighlightedChannel.ID)) 288 req, _ := http.NewRequest(requestMethod, url, bytes.NewBuffer(jsonStr)) 289 290 req.Header.Add("Sec-Fetch-Mode", "cors") 291 req.Header.Add("Sec-Fetch-Dest", "empty") 292 req.Header.Add("Sec-Fetch-Site", "same-origin") 293 req.Header.Add("X-Session-Key", config.GetSessionKey()) 294 295 resp, err := client.Do(req) 296 if err != nil || (resp.StatusCode != 204 && resp.StatusCode != 200) { 297 ctx.SetStatusMessage("There was a problem updating channel favorites") 298 return 299 } 300 defer resp.Body.Close() 301 } 302 303 // Stream streams the provided URL using the given di.fm premium token 304 func Stream(url string, ctx *context.AppContext) { 305 client := http.DefaultClient 306 u := fmt.Sprintf("%s?%s", url, config.GetToken()) 307 308 // Keep increasing playback latency by one second for every time that the player exits with EOF 309 // while ctx.IsPlaying 310 for playbackLatency := 1; ctx.IsPlaying; playbackLatency++ { 311 ctx.SetStatusMessage("Buffering stream...") 312 if ctx.Player != nil { 313 ctx.Player.Close() 314 } 315 316 req, _ := http.NewRequest("GET", u, nil) 317 resp, err := client.Do(req) 318 if err != nil || resp.StatusCode != 200 { 319 ctx.SetStatusMessage("There was a problem streaming audio.") 320 return 321 } 322 323 ctx.AudioStream = resp.Body 324 audioBytes := &bytes.Buffer{} 325 audioStream := bufio.NewReadWriter(bufio.NewReader(audioBytes), bufio.NewWriter(audioBytes)) 326 327 go func() { 328 for { 329 _, err = io.CopyN(audioStream, resp.Body, 512) 330 if err != nil { 331 return 332 } 333 } 334 }() 335 336 time.Sleep(time.Duration(playbackLatency) * time.Second) 337 err = player.Play(ctx, audioStream, playbackLatency) 338 if err == nil { 339 return 340 } else { 341 resp.Body.Close() 342 continue 343 } 344 } 345 } 346 347 // FavoriteItemChannel identifies the ChannelItem that corresponds with a FavoriteItem 348 func FavoriteItemChannel(ctx *context.AppContext, favorite components.FavoriteItem) (channel *components.ChannelItem) { 349 for _, chn := range ctx.ChannelList { 350 // favorites are prefixed with "DI.fm - <CHANNEL NAME>", shave it off before comparing 351 // TODO: this feels a bit hacky -- consider doing something else. 352 if chn.Name == favorite.Name[8:len(favorite.Name)] { 353 channel = &chn 354 return 355 } 356 } 357 358 return 359 }