359 lines
10 KiB
Go
359 lines
10 KiB
Go
package difm
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/acaloiaro/di-tui/context"
|
|
"github.com/acaloiaro/di-tui/player"
|
|
|
|
"github.com/acaloiaro/di-tui/components"
|
|
"github.com/acaloiaro/di-tui/config"
|
|
"github.com/bradfitz/iter"
|
|
ini "gopkg.in/ini.v1"
|
|
)
|
|
|
|
type ApplicationMetadata struct {
|
|
User struct {
|
|
ID int64 `json:"id"`
|
|
AudioToken string `json:"audio_token"`
|
|
SessionKey string `json:"session_key"`
|
|
} `json:"user"`
|
|
CsrfToken string
|
|
}
|
|
|
|
type authResponse struct {
|
|
ListenKey string `json:"listen_key"`
|
|
}
|
|
|
|
// Authenticate authenticates to the di.fm API with a username and password
|
|
//
|
|
// Note: There is a more API-friendly way of authenticating to the audioaddict API. However, because only the "web
|
|
// player" allows interactions such as adding/removing favorite channels, we're obligated to authenticate in the way
|
|
// that the web player authenticates.
|
|
|
|
// login workflow
|
|
// 1. GET www.di.fm/login to get the CSRF token
|
|
// 2. POST www.di.fm/login (with CSRF token and other appropriate headers)
|
|
// 3. GET www.di.fm/ to retrieve two key pieces of information
|
|
// - an "audio_token"
|
|
// - a "session_key"
|
|
//
|
|
// Both of which are required to perform advanced player features such as adding/removing favorites.
|
|
//
|
|
// One added benefit of logging in this way is that di-tui may use the audio token to stream "on-demand" audio in the
|
|
// future, which would allow the player to buffer content. Currently, no buffering is performed because di-tui uses the
|
|
// streaming API.
|
|
//
|
|
// However, until a developer-friendly, go-native AAC decoder is available, this player will
|
|
// continue using the MP3 "streaming" API.
|
|
func Authenticate(ctx *context.AppContext, username, password string) (token string) {
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
log.Fatalf("Got error while creating cookie jar %s", err.Error())
|
|
}
|
|
client := &http.Client{Jar: jar}
|
|
|
|
// 1. GET www.di.fm/login to get the CSRF token
|
|
meta, _ := getApplicationMetadata(ctx, client)
|
|
authURL := "https://www.di.fm/login"
|
|
data := url.Values{}
|
|
data.Set("member_session[username]", username)
|
|
data.Set("member_session[password]", password)
|
|
|
|
encodedData := data.Encode()
|
|
|
|
// 2. POST www.di.fm/login (with CSRF token and other appropriate headers)
|
|
req, _ := http.NewRequest("POST", authURL, strings.NewReader(encodedData))
|
|
req.Header.Add("Content-Length", strconv.Itoa(len(encodedData)))
|
|
req.Header.Add("Origin", "https://www.di.fm")
|
|
req.Header.Add("Referrer", "https://www.di.fm")
|
|
req.Header.Add("Sec-Fetch-Mode", "cors")
|
|
req.Header.Add("Sec-Fetch-Dest", "empty")
|
|
req.Header.Add("Sec-Fetch-Site", "same-origin")
|
|
req.Header.Add("X-Requested-With", "XMLHttpRequest")
|
|
req.Header.Add("X-CSRF-Token", meta.CsrfToken)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Fatal("authentication failed", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var res authResponse
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
fmt.Println("Unable to authenticate to di.fm. Status code:", resp.StatusCode)
|
|
os.Exit(1)
|
|
}
|
|
|
|
json.Unmarshal(body, &res)
|
|
config.SaveListenToken(res.ListenKey)
|
|
|
|
// 3. GET www.di.fm/ to retrieve two key pieces of information
|
|
meta, err = getApplicationMetadata(ctx, client)
|
|
if err != nil {
|
|
fmt.Println("Unable to fetch audio token and session key")
|
|
os.Exit(1)
|
|
}
|
|
|
|
config.SaveAudioToken(meta.User.AudioToken)
|
|
config.SaveSessionKey(meta.User.SessionKey)
|
|
config.SaveUserID(meta.User.ID)
|
|
|
|
return
|
|
}
|
|
|
|
// GetApplicationMetadata fetches the application's metadata (the di.fm player application) from www.di.fm
|
|
func getApplicationMetadata(ctx *context.AppContext, client *http.Client) (appMeta ApplicationMetadata, err error) {
|
|
var req *http.Request
|
|
req, err = http.NewRequest("GET", "https://www.di.fm", nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
req.Header.Add("Sec-Fetch-Mode", "cors")
|
|
req.Header.Add("Sec-Fetch-Dest", "empty")
|
|
req.Header.Add("Sec-Fetch-Site", "same-origin")
|
|
|
|
var resp *http.Response
|
|
resp, err = client.Do(req)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var body []byte
|
|
body, err = io.ReadAll(resp.Body)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
return
|
|
}
|
|
|
|
bodyStr := string(body)
|
|
re := regexp.MustCompile(`.*di\.app\.start\((.*)\);.*`)
|
|
matches := re.FindStringSubmatch(bodyStr)
|
|
if len(matches) > 0 {
|
|
appMeta = ApplicationMetadata{}
|
|
err = json.Unmarshal([]byte(matches[1]), &appMeta)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
re = regexp.MustCompile(`.*meta name="csrf-token" content="(.*?)"/>`)
|
|
matches = re.FindStringSubmatch(bodyStr)
|
|
if len(matches) > 0 {
|
|
appMeta.CsrfToken = matches[1]
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// GetStreamURL extracts a playlist's stream URL from raw INI bytes (pls file)
|
|
func GetStreamURL(data []byte, ctx *context.AppContext) (streamURL string, ok bool) {
|
|
cfg, err := ini.Load(data)
|
|
if err != nil {
|
|
ctx.SetStatusMessage("Unable to fetch channel playlist file.")
|
|
ok = false
|
|
return
|
|
}
|
|
|
|
streamURL = cfg.Section("playlist").Key("File1").String()
|
|
ok = streamURL != ""
|
|
|
|
return
|
|
}
|
|
|
|
// GetCurrentlyPlaying fetches the list of all currently playing tracks site-side
|
|
func GetCurrentlyPlaying(ctx *context.AppContext) (currentlyPlaying components.CurrentlyPlaying) {
|
|
client := &http.Client{}
|
|
req, _ := http.NewRequest("GET", "https://api.audioaddict.com/v1/di/currently_playing", nil)
|
|
resp, err := client.Do(req)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
ctx.SetStatusMessage("Unable to fetch currently playing track info")
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
ctx.SetStatusMessage("Unable to fetch currently playing track info.")
|
|
|
|
return
|
|
}
|
|
|
|
var currentlyPlayingStations []components.CurrentlyPlaying
|
|
json.Unmarshal(body, ¤tlyPlayingStations)
|
|
|
|
for _, cp := range currentlyPlayingStations {
|
|
if cp.ChannelID == ctx.CurrentChannel.ID {
|
|
return cp
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// ListChannels lists all premium MP3 channels
|
|
func ListChannels(ctx *context.AppContext) (channels []components.ChannelItem) {
|
|
client := &http.Client{}
|
|
req, _ := http.NewRequest("GET", "http://listen.di.fm/premium_high", nil)
|
|
resp, err := client.Do(req)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
ctx.SetStatusMessage("Unable to fetch the list of channels")
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
ctx.SetStatusMessage("Unable to fetch the list of channels")
|
|
return
|
|
}
|
|
|
|
err = json.Unmarshal(body, &channels)
|
|
if err != nil {
|
|
ctx.SetStatusMessage("Unable to fetch the list of channels")
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// ListFavorites lists a user's favorite channels
|
|
func ListFavorites(ctx *context.AppContext) (favorites []components.FavoriteItem) {
|
|
client := &http.Client{}
|
|
url := fmt.Sprintf("%s?%s", "http://listen.di.fm/premium_high/favorites.pls", ctx.DifmToken)
|
|
req, _ := http.NewRequest("GET", url, nil)
|
|
resp, err := client.Do(req)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
ctx.SetStatusMessage("There was a problem fetching your favorites")
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
cfg, err := ini.Load(body)
|
|
if err != nil {
|
|
ctx.SetStatusMessage("There was a problem fetching your favorites")
|
|
return
|
|
}
|
|
|
|
sec := "playlist"
|
|
numEntries := cfg.Section(sec).Key("NumberOfEntries").MustInt(0)
|
|
for i := range iter.N(numEntries) {
|
|
// di.fm's PLS keys begin at 1
|
|
k := i + 1
|
|
favorites = append(favorites, components.FavoriteItem{
|
|
Name: cfg.Section(sec).Key(fmt.Sprintf("Title%d", k)).String(),
|
|
PlaylistURL: cfg.Section(sec).Key(fmt.Sprintf("File%d", k)).String(),
|
|
})
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// ToggleFavorite adds/removes the currentlly selected channel to/from the user's favorites
|
|
func ToggleFavorite(ctx *context.AppContext) {
|
|
if config.GetSessionKey() == "" {
|
|
ctx.SetStatusMessage("Unfortunately you must log in to di-tui with a username and password to change favorites.")
|
|
return
|
|
}
|
|
|
|
client := &http.Client{}
|
|
url := fmt.Sprintf("https://api.audioaddict.com/v1/di/members/%d/favorites/channel/%d", config.GetUserID(), ctx.HighlightedChannel.ID)
|
|
|
|
requestMethod := "POST"
|
|
focusedView := ctx.View.App.GetFocus()
|
|
// if the user is currently viewing favorites, then the request is to remove the channel from the favorite list
|
|
if focusedView == ctx.View.FavoriteList {
|
|
requestMethod = "DELETE"
|
|
}
|
|
|
|
url = fmt.Sprintf("%s?audio_token=%s", url, config.GetAudioToken())
|
|
|
|
var jsonStr = []byte(fmt.Sprintf(`{"id": %d}`, ctx.HighlightedChannel.ID))
|
|
req, _ := http.NewRequest(requestMethod, url, bytes.NewBuffer(jsonStr))
|
|
|
|
req.Header.Add("Sec-Fetch-Mode", "cors")
|
|
req.Header.Add("Sec-Fetch-Dest", "empty")
|
|
req.Header.Add("Sec-Fetch-Site", "same-origin")
|
|
req.Header.Add("X-Session-Key", config.GetSessionKey())
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil || (resp.StatusCode != 204 && resp.StatusCode != 200) {
|
|
ctx.SetStatusMessage("There was a problem updating channel favorites")
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
}
|
|
|
|
// Stream streams the provided URL using the given di.fm premium token
|
|
func Stream(url string, ctx *context.AppContext) {
|
|
client := http.DefaultClient
|
|
u := fmt.Sprintf("%s?%s", url, config.GetToken())
|
|
|
|
// Keep increasing playback latency by one second for every time that the player exits with EOF
|
|
// while ctx.IsPlaying
|
|
for playbackLatency := 1; ctx.IsPlaying; playbackLatency++ {
|
|
ctx.SetStatusMessage("Buffering stream...")
|
|
if ctx.Player != nil {
|
|
ctx.Player.Close()
|
|
}
|
|
|
|
req, _ := http.NewRequest("GET", u, nil)
|
|
resp, err := client.Do(req)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
ctx.SetStatusMessage("There was a problem streaming audio.")
|
|
return
|
|
}
|
|
|
|
ctx.AudioStream = resp.Body
|
|
audioBytes := &bytes.Buffer{}
|
|
audioStream := bufio.NewReadWriter(bufio.NewReader(audioBytes), bufio.NewWriter(audioBytes))
|
|
|
|
go func() {
|
|
for {
|
|
_, err = io.CopyN(audioStream, resp.Body, 512)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
time.Sleep(time.Duration(playbackLatency) * time.Second)
|
|
err = player.Play(ctx, audioStream, playbackLatency)
|
|
if err == nil {
|
|
return
|
|
} else {
|
|
resp.Body.Close()
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// FavoriteItemChannel identifies the ChannelItem that corresponds with a FavoriteItem
|
|
func FavoriteItemChannel(ctx *context.AppContext, favorite components.FavoriteItem) (channel *components.ChannelItem) {
|
|
for _, chn := range ctx.ChannelList {
|
|
// favorites are prefixed with "DI.fm - <CHANNEL NAME>", shave it off before comparing
|
|
// TODO: this feels a bit hacky -- consider doing something else.
|
|
if chn.Name == favorite.Name[8:len(favorite.Name)] {
|
|
channel = &chn
|
|
return
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|