diff --git a/artfetcher.go b/artfetcher.go new file mode 100644 index 0000000..8c4eb4e --- /dev/null +++ b/artfetcher.go @@ -0,0 +1,7 @@ +package main + +import "github.com/godbus/dbus/v5" + +type artFetcher interface{ + getAlbumArt(artist, album, title string, metadata map[string]dbus.Variant) string +} diff --git a/assets.go b/assets.go new file mode 100644 index 0000000..7bcdbd6 --- /dev/null +++ b/assets.go @@ -0,0 +1,13 @@ +package main + +type Asset struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type AssetToUpload struct { + Name string `json:"name"` + Type string `json:"type"` // always 1 + Image string `json:"image"` // base64 encoded +} + diff --git a/config.go b/config.go index e868f5a..c119c5e 100644 --- a/config.go +++ b/config.go @@ -8,6 +8,8 @@ type config struct { PlayerPresence map[string]presenceConfig `json:"playerPresence"` Vars []string `json:"vars"` LogLevel string `json:"logLevel"` + ShowAlbumArt bool `json:"showAlbumArt"` + ArtFetchMethod string `json:"artFetchMethod"` } type presenceConfig struct { diff --git a/discord.go b/discord.go new file mode 100644 index 0000000..e83335b --- /dev/null +++ b/discord.go @@ -0,0 +1,175 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "regexp" + "sort" + "strings" + "net/url" + + "github.com/godbus/dbus/v5" +) + +var tokenRegex = regexp.MustCompile(`mfa\.[\w-]{84}`) +var receivedAssets []Asset + +type discordFetcher struct{} + +func (discordFetcher) getAlbumArt(artist, album, title string, mdata map[string]dbus.Variant) string { + artFile := "" + if artUrl, ok := mdata["mpris:artUrl"].Value().(string); ok { + artFile, _ = url.PathUnescape(artUrl) + // remove file:// from the beginning + artFile = artFile[7:] + } + + albumAsset, err := checkForAsset(album) + fmt.Println(err, albumAsset) + if err != nil { + fmt.Println("Uploading " + artFile + " to discord") + albumAsset, err = uploadAsset(artFile, album) + fmt.Println(err) + } + + return albumAsset +} + +// function to get token from local discord db +func getDiscordToken() string { + discordDir := os.Getenv("HOME") + "/.config/discord" + dbDir := discordDir + "/Local Storage/leveldb/" + // get files in dbDir + files, err := os.ReadDir(dbDir) + dbs := []fs.DirEntry{} + if err != nil { + fmt.Println(err) + return "" + } + // add file to dbs if it ends with .ldb + for _, file := range files { + if strings.HasSuffix(file.Name(), ".ldb") { + dbs = append(dbs, file) + } + } + + // sort by modification time + sort.Slice(files, func(i, j int) bool { + firstFileInfo, _ := files[i].Info() + secondFileInfo, _ := files[j].Info() + return firstFileInfo.ModTime().After(secondFileInfo.ModTime()) + }) + + // go through all leveldbs to find the one with the token + for _, dbFile := range dbs { + dbContents, err := os.ReadFile(dbDir + dbFile.Name()) + if err != nil { + fmt.Println(err) + return "" + } + + // return single regex match + token := tokenRegex.FindString(string(dbContents)) + if token != "" { + return token + } + } + + return "" +} + +func getAssets() []Asset { + // make get request to get assets + if len(receivedAssets) != 0 { + return receivedAssets + } + resp, err := http.Get("https://discord.com/api/v9/oauth2/applications/902662551119224852/assets") + if err != nil { + fmt.Println(err) + return []Asset{} + } + + var assets []Asset + json.NewDecoder(resp.Body).Decode(&assets) + + receivedAssets = assets + return receivedAssets +} + +// upload asset to discord +// assetName will be the album name, or song name if no album +// "music" is returned as the assetName when an error occurs since that's the name of just a music icon +func uploadAsset(fileName, assetName string) (string, error) { + image, err := os.ReadFile(fileName) + if err != nil { + return "music", err + } + + base64Encoding := "" + + // determine the content type of the image file + imageType := http.DetectContentType(image) + + // album art is only going to be jpg or png, right? + // if not i hate you + switch imageType { + case "image/jpeg": + base64Encoding += "data:image/jpeg;base64," + case "image/png": + base64Encoding += "data:image/png;base64," + } + + base64Encoding += base64.StdEncoding.EncodeToString(image) + + // turn assetName into a hex encoded string + assetNameEncoded := hex.EncodeToString([]byte(assetName)) + + asset := AssetToUpload{ + Type: "1", + Name: assetNameEncoded, + Image: base64Encoding, + } + + // make post request to upload asset, using token in Authentication header + jsonBytes, err := json.Marshal(asset) + req, _ := http.NewRequest("POST", "https://discord.com/api/v9/oauth2/applications/902662551119224852/assets", bytes.NewBuffer(jsonBytes)) + + req.Header.Set("Authorization", getDiscordToken()) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "music", err + } + var receivedAssetInfo Asset + json.NewDecoder(resp.Body).Decode(&receivedAssetInfo) + resp.Body.Close() + fmt.Println(receivedAssetInfo) + + receivedAssets = append(receivedAssets, receivedAssetInfo) + + return assetName, nil +} + +func checkForAsset(albumName string) (string, error) { + // get assets from discord + assets := getAssets() + + albumNameEncoded := hex.EncodeToString([]byte(albumName)) + + // go through assets to see if one matches fileName + for _, asset := range assets { + if asset.Name == albumNameEncoded { + fmt.Println("found asset") + return asset.Name, nil + } + } + + return "", fmt.Errorf("No asset found for %s", albumName) +} diff --git a/main.go b/main.go index 21d22c1..5e0ec71 100644 --- a/main.go +++ b/main.go @@ -25,8 +25,12 @@ var conf = config{ State: "{artist} {album}", }, LogLevel: "info", + ShowAlbumArt: true, + ArtFetchMethod: "spotify", } + var logger = slogx.NewLogger("Clematis") +var fetcher artFetcher func main() { logger.SetFormat("${time} ${level} > ${message}") @@ -60,6 +64,11 @@ func main() { logger.Error("Error reading config file: ", err) } json.Unmarshal(confFile, &conf) + switch conf.ArtFetchMethod { + case "spotify": fetcher = spotifyFetcher{} + case "discord": fetcher = discordFetcher{} + default: panic(fmt.Sprintf("Invalid album art fetcher %s. The valid options are: spotify, discord", conf.ArtFetchMethod)) + } logger.SetLevel(slogx.ParseLevel(conf.LogLevel)) @@ -212,8 +221,10 @@ func setPresence(metadata map[string]dbus.Variant, songstamp time.Time, player * } } album := "" + albumName := "" if abm, ok := metadata["xesam:album"].Value().(string); ok { - album = "on " + abm + albumName = abm + album = "on " + albumName } if pbStat != "Playing" { startstamp, endstamp = nil, nil @@ -221,11 +232,13 @@ func setPresence(metadata map[string]dbus.Variant, songstamp time.Time, player * artistsStr := "" if artistsArr, ok := metadata["xesam:artist"].Value().([]string); ok { - artistsStr = "by " + strings.Join(artistsArr, ", ") + artistsStr = strings.Join(artistsArr, ", ") } + url := fetcher.getAlbumArt(artistsStr, albumName, title, metadata) + args := []string{ - "{artist}", artistsStr, + "{artist}", "by " + artistsStr, "{title}", title, "{album}", album, } @@ -243,7 +256,7 @@ func setPresence(metadata map[string]dbus.Variant, songstamp time.Time, player * client.SetActivity(client.Activity{ Details: replacer.Replace(p.Details), State: replacer.Replace(p.State), - LargeImage: "music", + LargeImage: url, LargeText: playerIdentity, SmallImage: strings.ToLower(string(pbStat)), SmallText: string(pbStat), diff --git a/spotify.go b/spotify.go new file mode 100644 index 0000000..96e396f --- /dev/null +++ b/spotify.go @@ -0,0 +1,116 @@ +package main + +// credit to https://github.com/lacymorrow/album-art +// thanks for the token too :) + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "net/http" + "net/url" + // "sort" + "strings" + + "github.com/godbus/dbus/v5" +) + +const ( + art_endpoint = "https://api.spotify.com/v1" + auth_endpoint = "https://accounts.spotify.com/api/token" + art_api_id = "3f974573800a4ff5b325de9795b8e603" + art_api_secret = "ff188d2860ff44baa57acc79c121a3b9" + art_api_auth = art_api_id + ":" + art_api_secret +) + +type spotifyFetcher struct{} + +type spotifyAccess struct{ + AccessToken string `json:"access_token"` +} + +type spotifySeach struct{ + Tracks spotifyTrack `json:"tracks"` +} + +type spotifyTrack struct{ + Items []spotifyTrackObject `json:"items"` +} + +type spotifyTrackObject struct{ + Album spotifyAlbum `json:"album"` +} + +type spotifyAlbum struct{ + Images []spotifyArt +} + +type spotifyArt struct { + Width int + Height int + URL string +} + +func handleSpotErr(err error) string { + fmt.Fprintln(os.Stderr, err) + return "music" +} + +func (spotifyFetcher) getAlbumArt(artist, album, title string, mdata map[string]dbus.Variant) string { + spotSearchQuery := url.PathEscape(url.QueryEscape(fmt.Sprintf("track:%s artist:%s", title, artist))) + artUrl, _ := url.Parse(fmt.Sprintf("%s/search?q=%s&type=track&limit=1", art_endpoint, spotSearchQuery)) + authUrl, _ := url.Parse(auth_endpoint) + + req, err := http.NewRequest("POST", authUrl.String(), strings.NewReader("grant_type=client_credentials")) + if err != nil { + return handleSpotErr(err) + } + req.Header.Add("Authorization", "Basic " + base64.StdEncoding.EncodeToString([]byte(art_api_auth))) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return handleSpotErr(err) + } + + body, err := io.ReadAll(resp.Body) + spot := &spotifyAccess{} + if err := json.Unmarshal(body, &spot); err != nil { + return handleSpotErr(err) + } + + req, err = http.NewRequest("GET", artUrl.String(), strings.NewReader("")) + req.Header.Add("Authorization", "Bearer " + spot.AccessToken) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err = http.DefaultClient.Do(req) + if err != nil { + return handleSpotErr(err) + } + + body, err = io.ReadAll(resp.Body) + logger.Debug(string(body)) + + spotifyData := &spotifySeach{} + if err := json.Unmarshal(body, &spotifyData); err != nil { + return handleSpotErr(err) + } + + if len(spotifyData.Tracks.Items) == 0 { + // nothing found + fmt.Fprintln(os.Stderr, "Nothing found on spotify for %s", album) + return "music" + } + + images := spotifyData.Tracks.Items[0].Album.Images + // may or may not be needed (will see) + /* + sort.Slice(images, func(i, j int) bool { + return images[i].Width > images[j].Width + }) + */ + + return images[0].URL +}