dischord/extractor/spotify/spotify.go

379 lines
9.5 KiB
Go

package spotify
import (
"git.nobrain.org/r4/dischord/extractor"
exutil "git.nobrain.org/r4/dischord/extractor/util"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
var (
ErrGettingSessionData = errors.New("unable to get session data")
ErrInvalidTrackData = errors.New("invalid track data")
ErrTrackNotFound = errors.New("unable to find track on YouTube")
ErrUnableToGetYoutubeStream = errors.New("unable to get YouTube stream")
ErrDecodingApiResponse = errors.New("error decoding API response")
)
// distance between two integers
func iDist(a, b int) int {
if a > b {
return a - b
} else {
return b - a
}
}
func containsIgnoreCase(s, substr string) bool {
return strings.Contains(strings.ToUpper(s), strings.ToUpper(substr))
}
type sessionData struct {
AccessToken string `json:"accessToken"`
AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"`
}
type apiToken struct {
token string
expires time.Time
}
func updateApiToken(token *apiToken) error {
if time.Now().Before(token.expires) {
// Token already up-to-date
return nil
}
// Get new token
var data sessionData
var funcErr error
err := exutil.GetHTMLScriptFunc("https://open.spotify.com", false, func(code string) bool {
if strings.HasPrefix(code, "{\"accessToken\":\"") {
// Parse session data
if err := json.Unmarshal([]byte(code), &data); err != nil {
funcErr = err
return false
}
return false
}
return true
})
if err != nil {
return err
}
if funcErr != nil {
return funcErr
}
*token = apiToken{
token: data.AccessToken,
expires: time.UnixMilli(data.AccessTokenExpirationTimestampMs),
}
return nil
}
type trackData struct {
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
DurationMs int `json:"duration_ms"`
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
Name string `json:"name"`
}
func (d trackData) artistsString() (res string) {
for i, v := range d.Artists {
if i != 0 {
res += ", "
}
res += v.Name
}
return
}
func (d trackData) titleString() string {
return d.artistsString() + " - " + d.Name
}
func getTrack(e *Extractor, trackId string) (extractor.Data, error) {
if err := updateApiToken(&e.token); err != nil {
return extractor.Data{}, err
}
// Make API request for track info
req, err := http.NewRequest("GET", "https://api.spotify.com/v1/tracks/"+trackId, nil)
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", "Bearer "+e.token.token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return extractor.Data{}, err
}
defer resp.Body.Close()
// Parse track info
var data trackData
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&data); err != nil {
return extractor.Data{}, ErrDecodingApiResponse
}
if len(data.Artists) == 0 {
return extractor.Data{}, ErrInvalidTrackData
}
// Search for track on YouTube
results, err := e.ytSearcher.Search(e.ytSearcherConfig, data.Name+" - "+data.artistsString())
if err != nil {
return extractor.Data{}, err
}
if len(results) == 0 {
return extractor.Data{}, ErrTrackNotFound
}
// Lower is better
score := func(ytd extractor.Data, resIdx int) (score int) {
// This function determines the likelihood of a given YouTube video
// belonging to the Spotify song.
// It may look pretty complicated, but here's the gist:
// - lower scores are better
// - the general formula is: resIdx - matchAccuracy / penalty
// where 'resIdx' is the position in the search results,
// 'matchAccuracy' is how well the video superficially matches
// with the Spotify song (title, artists, duration) and 'penalty'
// measures the hints pointing to the current video being the
// wrong one (awfully wrong duration, instrumental version, remix
// etc.)
// - if the video is from an official artist channel, that makes the
// penalty points even more credible, so they're squared
// - accuracy and penalty points are multiplicative; this makes them
// have exponentially more weight the more they are given
matchAccuracy := 1.0
matchPenalty := 1.0
sqrPenalty := false
if ytd.OfficialArtist || strings.HasSuffix(ytd.Uploader, " - Topic") {
matchAccuracy *= 4.0
sqrPenalty = true
}
if containsIgnoreCase(ytd.Title, data.Name) {
matchAccuracy *= 4.0
}
matchingArtists := 0.0
firstMatches := false
for i, artist := range data.Artists {
if containsIgnoreCase(ytd.Uploader, artist.Name) ||
containsIgnoreCase(ytd.Title, artist.Name) {
matchingArtists += 1.0
if i == 0 {
firstMatches = true
}
}
}
if firstMatches {
matchAccuracy *= 2.0
}
matchAccuracy *= 2.0 * (matchingArtists / float64(len(data.Artists)))
durationDist := iDist(ytd.Duration, data.DurationMs/1000)
if durationDist <= 5 {
matchAccuracy *= 8.0
} else if durationDist >= 300 {
matchPenalty *= 16.0
}
spotiArtists := data.artistsString()
onlyYtTitleContains := func(s string) bool {
return !containsIgnoreCase(data.Name, s) &&
!containsIgnoreCase(spotiArtists, s) &&
containsIgnoreCase(ytd.Title, s)
}
if onlyYtTitleContains("instrumental") || onlyYtTitleContains("cover") ||
onlyYtTitleContains("live") || onlyYtTitleContains("album") {
matchPenalty *= 8.0
}
if onlyYtTitleContains("remix") || onlyYtTitleContains("rmx") {
matchPenalty *= 8.0
} else if onlyYtTitleContains("mix") {
matchPenalty *= 6.0
}
if onlyYtTitleContains("vip") {
matchPenalty *= 6.0
}
totalPenalty := matchPenalty
if sqrPenalty {
totalPenalty *= totalPenalty
}
return resIdx - int(matchAccuracy/totalPenalty)
}
// Select the result with the lowest (best) score
lowestIdx := -1
lowest := 2147483647
for i, v := range results {
score := score(v, i)
//fmt.Println(i, score, v)
if score < lowest {
lowestIdx = i
lowest = score
}
}
ytData, err := e.ytExtractor.Extract(e.ytExtractorConfig, results[lowestIdx].SourceUrl)
if err != nil {
return extractor.Data{}, err
}
if len(ytData) != 1 {
return extractor.Data{}, ErrUnableToGetYoutubeStream
}
return extractor.Data{
SourceUrl: data.ExternalUrls.Spotify,
StreamUrl: ytData[0].StreamUrl,
Title: data.titleString(),
Uploader: data.artistsString(),
Duration: ytData[0].Duration,
Expires: ytData[0].Expires,
}, nil
}
type playlistData struct {
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
Name string `json:"name"`
Tracks struct {
Items []struct {
Track trackData `json:"track"`
} `json:"items"`
Next string `json:"next"`
} `json:"tracks"`
}
func getPlaylist(e *Extractor, playlistId string) ([]extractor.Data, error) {
if err := updateApiToken(&e.token); err != nil {
return nil, err
}
var data playlistData
trackOnlyReq := false
reqUrl := "https://api.spotify.com/v1/playlists/" + playlistId
var res []extractor.Data
for {
// Make API request for playlist info
req, err := http.NewRequest("GET", reqUrl, nil)
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", "Bearer "+e.token.token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Parse playlist info
dec := json.NewDecoder(resp.Body)
if trackOnlyReq {
// JSON decoder doesn't always overwrite the set value
data.Tracks.Next = ""
data.Tracks.Items = nil
err = dec.Decode(&data.Tracks)
} else {
err = dec.Decode(&data)
}
if err != nil {
return nil, ErrDecodingApiResponse
}
for _, v := range data.Tracks.Items {
res = append(res, extractor.Data{
SourceUrl: v.Track.ExternalUrls.Spotify,
Title: v.Track.titleString(),
Uploader: v.Track.artistsString(),
PlaylistUrl: data.ExternalUrls.Spotify,
PlaylistTitle: data.Name,
})
}
if data.Tracks.Next == "" {
break
} else {
reqUrl = data.Tracks.Next
trackOnlyReq = true
}
}
return res, nil
}
type albumData struct {
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
Name string `json:"name"`
Tracks struct {
Items []trackData `json:"items"`
Next string `json:"next"`
} `json:"tracks"`
}
func getAlbum(e *Extractor, albumId string) ([]extractor.Data, error) {
// This function is pretty much copied from getPlaylist, with minor
// modifications
if err := updateApiToken(&e.token); err != nil {
return nil, err
}
var data albumData
trackOnlyReq := false
reqUrl := "https://api.spotify.com/v1/albums/" + albumId
var res []extractor.Data
for {
// Make API request for album info
req, err := http.NewRequest("GET", reqUrl, nil)
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", "Bearer "+e.token.token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Parse album info
dec := json.NewDecoder(resp.Body)
if trackOnlyReq {
// JSON decoder doesn't always overwrite the set value
data.Tracks.Next = ""
data.Tracks.Items = nil
err = dec.Decode(&data.Tracks)
} else {
err = dec.Decode(&data)
}
if err != nil {
return nil, ErrDecodingApiResponse
}
for _, v := range data.Tracks.Items {
res = append(res, extractor.Data{
SourceUrl: v.ExternalUrls.Spotify,
Title: v.titleString(),
Uploader: v.artistsString(),
PlaylistUrl: data.ExternalUrls.Spotify,
PlaylistTitle: data.Name,
})
}
if data.Tracks.Next == "" {
break
} else {
reqUrl = data.Tracks.Next
trackOnlyReq = true
}
}
return res, nil
}