/ difm / difm.go
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, &currentlyPlayingStations)
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  }