From 426d05fdcea0a8298c8e15b502317c2ea19cb319 Mon Sep 17 00:00:00 2001 From: Matt Gleich Date: Sat, 23 Nov 2024 03:04:54 -0500 Subject: [PATCH 1/3] feat: get recently played songs Signed-off-by: Matt Gleich --- .vscode/settings.json | 3 +- cmd/lcp.go | 36 +++++++++------------ go.sum | 2 -- internal/apis/applemusic/api.go | 44 ++++++++++++++++++++++++++ internal/apis/applemusic/applemusic.go | 7 ++++ internal/apis/applemusic/recent.go | 44 ++++++++++++++++++++++++++ internal/apis/applemusic/song.go | 33 +++++++++++++++++++ internal/secrets/secrets.go | 3 ++ 8 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 internal/apis/applemusic/api.go create mode 100644 internal/apis/applemusic/applemusic.go create mode 100644 internal/apis/applemusic/recent.go create mode 100644 internal/apis/applemusic/song.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 84253f5..f0193bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "github-actions.workflows.pinned.workflows": [".github/workflows/deploy.yml"] + "github-actions.workflows.pinned.workflows": [".github/workflows/deploy.yml"], + "cSpell.words": ["applemusic"] } diff --git a/cmd/lcp.go b/cmd/lcp.go index 3de3a56..97e5d6a 100644 --- a/cmd/lcp.go +++ b/cmd/lcp.go @@ -4,14 +4,8 @@ import ( "net/http" "time" - "github.com/gleich/lcp-v2/internal/apis/github" - "github.com/gleich/lcp-v2/internal/apis/steam" - "github.com/gleich/lcp-v2/internal/apis/strava" "github.com/gleich/lcp-v2/internal/secrets" "github.com/gleich/lumber/v3" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/prometheus/client_golang/prometheus/promhttp" ) func main() { @@ -20,21 +14,21 @@ func main() { secrets.Load() - r := chi.NewRouter() - r.Use(middleware.Recoverer) - r.Use(middleware.RedirectSlashes) - r.HandleFunc("/", rootRedirect) - r.HandleFunc("/metrics", promhttp.Handler().ServeHTTP) - - github.Setup(r) - strava.Setup(r) - steam.Setup(r) - - lumber.Info("starting server") - err := http.ListenAndServe(":8000", r) - if err != nil { - lumber.Fatal(err, "failed to start router") - } + // r := chi.NewRouter() + // r.Use(middleware.Recoverer) + // r.Use(middleware.RedirectSlashes) + // r.HandleFunc("/", rootRedirect) + // r.HandleFunc("/metrics", promhttp.Handler().ServeHTTP) + + // github.Setup(r) + // strava.Setup(r) + // steam.Setup(r) + + // lumber.Info("starting server") + // err := http.ListenAndServe(":8000", r) + // if err != nil { + // lumber.Fatal(err, "failed to start router") + // } } func setupLogger() { diff --git a/go.sum b/go.sum index c38c08b..5379ebf 100644 --- a/go.sum +++ b/go.sum @@ -35,8 +35,6 @@ github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90 github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/apis/applemusic/api.go b/internal/apis/applemusic/api.go new file mode 100644 index 0000000..0993430 --- /dev/null +++ b/internal/apis/applemusic/api.go @@ -0,0 +1,44 @@ +package applemusic + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/gleich/lcp-v2/internal/secrets" + "github.com/gleich/lumber/v3" +) + +func sendAPIRequest[T any](endpoint string) (T, error) { + var zeroValue T // to be used as "nil" when returning errors + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + lumber.Error(err, "creating request failed") + return zeroValue, err + } + req.Header.Set("Authorization", "Bearer "+secrets.SECRETS.AppleMusicAppToken) + req.Header.Set("Music-User-Token", secrets.SECRETS.AppleMusicUserToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + lumber.Error(err, "sending request failed") + return zeroValue, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + lumber.Error(err, "reading response body failed") + return zeroValue, err + } + + var data T + err = json.Unmarshal(body, &data) + if err != nil { + lumber.Error(err, "failed to parse json") + lumber.Debug(string(body)) + return zeroValue, err + } + + return data, nil +} diff --git a/internal/apis/applemusic/applemusic.go b/internal/apis/applemusic/applemusic.go new file mode 100644 index 0000000..a18e357 --- /dev/null +++ b/internal/apis/applemusic/applemusic.go @@ -0,0 +1,7 @@ +package applemusic + +import "github.com/go-chi/chi/v5" + +func Setup(router *chi.Mux) { + +} diff --git a/internal/apis/applemusic/recent.go b/internal/apis/applemusic/recent.go new file mode 100644 index 0000000..b2a0c3f --- /dev/null +++ b/internal/apis/applemusic/recent.go @@ -0,0 +1,44 @@ +package applemusic + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/gleich/lumber/v3" +) + +type recentlyPlayedResponse struct { + Next string `json:"next"` + Data songListData `json:"data"` +} + +func FetchRecentlyPlayed() { + response, err := sendAPIRequest[recentlyPlayedResponse]( + "https://api.music.apple.com/v1/me/recent/played/tracks", + ) + if err != nil { + lumber.Fatal(err, "failed to send request for recently played songs") + } + + var songs []song + for _, s := range response.Data { + songs = append(songs, song{ + Track: s.Attributes.Name, + Artist: s.Attributes.ArtistName, + Album: s.Attributes.AlbumName, + Genres: s.Attributes.GenreNames, + ReleaseDate: s.Attributes.ReleaseDate, + DurationInMillis: s.Attributes.DurationInMillis, + AlbumArtURL: strings.ReplaceAll(strings.ReplaceAll( + s.Attributes.Artwork.URL, + "{w}", + strconv.Itoa(s.Attributes.Artwork.Width), + ), "{h}", strconv.Itoa(s.Attributes.Artwork.Height)), + URL: s.Attributes.URL, + }) + } + + encodedData, _ := json.Marshal(songs) + lumber.Debug(string(encodedData)) +} diff --git a/internal/apis/applemusic/song.go b/internal/apis/applemusic/song.go new file mode 100644 index 0000000..f6a85ee --- /dev/null +++ b/internal/apis/applemusic/song.go @@ -0,0 +1,33 @@ +package applemusic + +type song struct { + Track string `json:"track"` + Artist string `json:"artist"` + Album string `json:"album"` + Genres []string `json:"genres"` + ReleaseDate string `json:"releaseDate"` + DurationInMillis int `json:"durationInMillis"` + AlbumArtURL string `json:"albumArtURL"` + URL string `json:"url"` +} + +type songListData []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + AlbumName string `json:"albumName"` + GenreNames []string `json:"genreNames"` + TrackNumber int `json:"trackNumber"` + ReleaseDate string `json:"releaseDate"` + DurationInMillis int `json:"durationInMillis"` + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + } `json:"artwork"` + URL string `json:"url"` + Name string `json:"name"` + ArtistName string `json:"artistName"` + } `json:"attributes"` +} diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go index 6a761a5..c58a247 100644 --- a/internal/secrets/secrets.go +++ b/internal/secrets/secrets.go @@ -28,6 +28,9 @@ type Secrets struct { SteamID string `env:"STEAM_ID"` GitHubAccessToken string `env:"GITHUB_ACCESS_TOKEN"` + + AppleMusicAppToken string `env:"APPLE_MUSIC_APP_TOKEN"` + AppleMusicUserToken string `env:"APPLE_MUSIC_USER_TOKEN"` } func Load() { From 365fa35b9f1d389448f40db9ac11338dddaca7c3 Mon Sep 17 00:00:00 2001 From: Matt Gleich Date: Sat, 23 Nov 2024 04:05:58 -0500 Subject: [PATCH 2/3] feat: get playlist data Signed-off-by: Matt Gleich --- cmd/lcp.go | 38 +++++++++------- internal/apis/applemusic/api.go | 11 ++++- internal/apis/applemusic/applemusic.go | 47 ++++++++++++++++++- internal/apis/applemusic/playlists.go | 62 ++++++++++++++++++++++++++ internal/apis/applemusic/recent.go | 36 +++------------ internal/apis/applemusic/song.go | 30 +++++++++++-- 6 files changed, 173 insertions(+), 51 deletions(-) create mode 100644 internal/apis/applemusic/playlists.go diff --git a/cmd/lcp.go b/cmd/lcp.go index 97e5d6a..d0471d6 100644 --- a/cmd/lcp.go +++ b/cmd/lcp.go @@ -4,8 +4,15 @@ import ( "net/http" "time" + "github.com/gleich/lcp-v2/internal/apis/applemusic" + "github.com/gleich/lcp-v2/internal/apis/github" + "github.com/gleich/lcp-v2/internal/apis/steam" + "github.com/gleich/lcp-v2/internal/apis/strava" "github.com/gleich/lcp-v2/internal/secrets" "github.com/gleich/lumber/v3" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/prometheus/client_golang/prometheus/promhttp" ) func main() { @@ -14,21 +21,22 @@ func main() { secrets.Load() - // r := chi.NewRouter() - // r.Use(middleware.Recoverer) - // r.Use(middleware.RedirectSlashes) - // r.HandleFunc("/", rootRedirect) - // r.HandleFunc("/metrics", promhttp.Handler().ServeHTTP) - - // github.Setup(r) - // strava.Setup(r) - // steam.Setup(r) - - // lumber.Info("starting server") - // err := http.ListenAndServe(":8000", r) - // if err != nil { - // lumber.Fatal(err, "failed to start router") - // } + r := chi.NewRouter() + r.Use(middleware.Recoverer) + r.Use(middleware.RedirectSlashes) + r.HandleFunc("/", rootRedirect) + r.HandleFunc("/metrics", promhttp.Handler().ServeHTTP) + + github.Setup(r) + strava.Setup(r) + steam.Setup(r) + applemusic.Setup(r) + + lumber.Info("starting server") + err := http.ListenAndServe(":8000", r) + if err != nil { + lumber.Fatal(err, "failed to start router") + } } func setupLogger() { diff --git a/internal/apis/applemusic/api.go b/internal/apis/applemusic/api.go index 0993430..db91f66 100644 --- a/internal/apis/applemusic/api.go +++ b/internal/apis/applemusic/api.go @@ -2,6 +2,7 @@ package applemusic import ( "encoding/json" + "fmt" "io" "net/http" @@ -11,7 +12,7 @@ import ( func sendAPIRequest[T any](endpoint string) (T, error) { var zeroValue T // to be used as "nil" when returning errors - req, err := http.NewRequest("GET", endpoint, nil) + req, err := http.NewRequest("GET", "https://api.music.apple.com/"+endpoint, nil) if err != nil { lumber.Error(err, "creating request failed") return zeroValue, err @@ -31,6 +32,14 @@ func sendAPIRequest[T any](endpoint string) (T, error) { lumber.Error(err, "reading response body failed") return zeroValue, err } + if resp.StatusCode != http.StatusOK { + err = fmt.Errorf( + "status code of %d returned from apple music API. Code of 200 expected", + resp.StatusCode, + ) + lumber.Error(err) + return zeroValue, err + } var data T err = json.Unmarshal(body, &data) diff --git a/internal/apis/applemusic/applemusic.go b/internal/apis/applemusic/applemusic.go index a18e357..fea1084 100644 --- a/internal/apis/applemusic/applemusic.go +++ b/internal/apis/applemusic/applemusic.go @@ -1,7 +1,52 @@ package applemusic -import "github.com/go-chi/chi/v5" +import ( + "time" + + "github.com/gleich/lcp-v2/internal/cache" + "github.com/gleich/lumber/v3" + "github.com/go-chi/chi/v5" +) + +type cacheData struct { + RecentlyPlayed []song `json:"recently_played"` + Playlists map[string]playlist `json:"playlists"` +} + +func cacheUpdate() (cacheData, error) { + recentlyPlayed, err := fetchRecentlyPlayed() + if err != nil { + return cacheData{}, err + } + + playlistsIDs := []string{ + "p.LV0PXNoCl0EpDLW", // DIVORCED DAD + "p.qQXLxPLtA75zg8e", // HIGHSCHOOL 1989 + "p.LV0PX3EIl0EpDLW", // jazz + } + playlists := map[string]playlist{} + for _, id := range playlistsIDs { + playlistData, err := fetchPlaylist(id) + if err != nil { + return cacheData{}, err + } + playlists[id] = playlistData + } + + return cacheData{ + RecentlyPlayed: recentlyPlayed, + Playlists: playlists, + }, nil +} func Setup(router *chi.Mux) { + data, err := cacheUpdate() + if err != nil { + lumber.Fatal(err, "initial fetch of cache data failed") + } + applemusicCache := cache.NewCache("applemusic", data) + router.Get("/applemusic/cache", applemusicCache.ServeHTTP()) + go applemusicCache.StartPeriodicUpdate(cacheUpdate, 1*time.Minute) + lumber.Done("setup apple music cache") } diff --git a/internal/apis/applemusic/playlists.go b/internal/apis/applemusic/playlists.go new file mode 100644 index 0000000..9cc78af --- /dev/null +++ b/internal/apis/applemusic/playlists.go @@ -0,0 +1,62 @@ +package applemusic + +import ( + "path" + "time" + + "github.com/gleich/lumber/v3" +) + +type playlist struct { + Name string `json:"name"` + Tracks []song `json:"tracks"` + LastModified time.Time `json:"last_modified"` +} + +type playlistTracksResponse struct { + Next string `json:"next"` + Data []songResponse `json:"data"` +} + +type playlistResponse struct { + Data []struct { + Attributes struct { + LastModifiedDate time.Time `json:"lastModifiedDate"` + Name string `json:"name"` + } `json:"attributes"` + } `json:"data"` +} + +func fetchPlaylist(id string) (playlist, error) { + playlistData, err := sendAPIRequest[playlistResponse](path.Join("v1/me/library/playlists/", id)) + if err != nil { + lumber.Error(err, "failed to fetch playlist for", id) + return playlist{}, err + } + + var totalResponseData []songResponse + trackData, err := sendAPIRequest[playlistTracksResponse]( + path.Join("v1/me/library/playlists/", id, "tracks"), + ) + if err != nil { + lumber.Error(err, "failed to get tracks for playlist with id of", id) + } + totalResponseData = append(totalResponseData, trackData.Data...) + for trackData.Next != "" { + trackData, err = sendAPIRequest[playlistTracksResponse](trackData.Next) + if err != nil { + lumber.Error(err, "failed to paginate through tracks for playlist with id of", id) + } + } + + var tracks []song + for _, t := range totalResponseData { + tracks = append(tracks, songFromSongResponse(t)) + } + + return playlist{ + Name: playlistData.Data[0].Attributes.Name, + LastModified: playlistData.Data[0].Attributes.LastModifiedDate, + Tracks: tracks, + }, nil +} diff --git a/internal/apis/applemusic/recent.go b/internal/apis/applemusic/recent.go index b2a0c3f..fb21a5d 100644 --- a/internal/apis/applemusic/recent.go +++ b/internal/apis/applemusic/recent.go @@ -1,44 +1,20 @@ package applemusic -import ( - "encoding/json" - "strconv" - "strings" - - "github.com/gleich/lumber/v3" -) - type recentlyPlayedResponse struct { - Next string `json:"next"` - Data songListData `json:"data"` + Data []songResponse `json:"data"` } -func FetchRecentlyPlayed() { +func fetchRecentlyPlayed() ([]song, error) { response, err := sendAPIRequest[recentlyPlayedResponse]( - "https://api.music.apple.com/v1/me/recent/played/tracks", + "v1/me/recent/played/tracks", ) if err != nil { - lumber.Fatal(err, "failed to send request for recently played songs") + return []song{}, err } var songs []song for _, s := range response.Data { - songs = append(songs, song{ - Track: s.Attributes.Name, - Artist: s.Attributes.ArtistName, - Album: s.Attributes.AlbumName, - Genres: s.Attributes.GenreNames, - ReleaseDate: s.Attributes.ReleaseDate, - DurationInMillis: s.Attributes.DurationInMillis, - AlbumArtURL: strings.ReplaceAll(strings.ReplaceAll( - s.Attributes.Artwork.URL, - "{w}", - strconv.Itoa(s.Attributes.Artwork.Width), - ), "{h}", strconv.Itoa(s.Attributes.Artwork.Height)), - URL: s.Attributes.URL, - }) + songs = append(songs, songFromSongResponse(s)) } - - encodedData, _ := json.Marshal(songs) - lumber.Debug(string(encodedData)) + return songs, nil } diff --git a/internal/apis/applemusic/song.go b/internal/apis/applemusic/song.go index f6a85ee..a66aadc 100644 --- a/internal/apis/applemusic/song.go +++ b/internal/apis/applemusic/song.go @@ -1,17 +1,22 @@ package applemusic +import ( + "strconv" + "strings" +) + type song struct { Track string `json:"track"` Artist string `json:"artist"` Album string `json:"album"` Genres []string `json:"genres"` - ReleaseDate string `json:"releaseDate"` - DurationInMillis int `json:"durationInMillis"` - AlbumArtURL string `json:"albumArtURL"` + ReleaseDate string `json:"release_date"` + DurationInMillis int `json:"duration_in_millis"` + AlbumArtURL string `json:"album_art_url"` URL string `json:"url"` } -type songListData []struct { +type songResponse struct { ID string `json:"id"` Type string `json:"type"` Href string `json:"href"` @@ -31,3 +36,20 @@ type songListData []struct { ArtistName string `json:"artistName"` } `json:"attributes"` } + +func songFromSongResponse(s songResponse) song { + return song{ + Track: s.Attributes.Name, + Artist: s.Attributes.ArtistName, + Album: s.Attributes.AlbumName, + Genres: s.Attributes.GenreNames, + ReleaseDate: s.Attributes.ReleaseDate, + DurationInMillis: s.Attributes.DurationInMillis, + AlbumArtURL: strings.ReplaceAll(strings.ReplaceAll( + s.Attributes.Artwork.URL, + "{w}", + strconv.Itoa(s.Attributes.Artwork.Width), + ), "{h}", strconv.Itoa(s.Attributes.Artwork.Height)), + URL: s.Attributes.URL, + } +} From 17e3807d8f4c8f871a4c230940b7cbf33d745a3e Mon Sep 17 00:00:00 2001 From: Matt Gleich Date: Sat, 23 Nov 2024 04:07:17 -0500 Subject: [PATCH 3/3] fix(lint): go mod tidy Signed-off-by: Matt Gleich --- go.sum | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.sum b/go.sum index 5379ebf..c38c08b 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90 github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=