diff --git a/content/notes/2024-06-20/18n24/index.md b/content/notes/2024-06-20/18n24/index.md index 72ed68a4..4a56bd5d 100644 --- a/content/notes/2024-06-20/18n24/index.md +++ b/content/notes/2024-06-20/18n24/index.md @@ -5,4 +5,4 @@ slug: 18n24 --- I search for "Oasis" on Spotify and before even a whole album's worth of tracks is listed, sits Girls & Boys by Blur 😂 The "rivalry" continues! -{{< spotify path="/track/5CeL9C3bsoe4yzYS1Qz8cw" title="Girls & Boys" artist="Blur" url="https://songwhip.com/blur/girls-and-boys2000" >}} +{{< music "8a8909c0-291c-4643-977f-17d27f052154" >}} diff --git a/content/notes/facebook/2016-10/vjht2n7owfjnowbdifsgbt33oa/index.md b/content/notes/facebook/2016-10/vjht2n7owfjnowbdifsgbt33oa/index.md index 56df756e..3a035788 100644 --- a/content/notes/facebook/2016-10/vjht2n7owfjnowbdifsgbt33oa/index.md +++ b/content/notes/facebook/2016-10/vjht2n7owfjnowbdifsgbt33oa/index.md @@ -1,5 +1,5 @@ --- -date: "2016-10-20T14:38:36Z" +date: 2016-10-20T14:38:36Z tags: - imported - from-facebook @@ -8,4 +8,4 @@ tags: --- Today this is my jam! (Who am I kidding, Vulfpeck are always my jam) -{{< spotify path="/track/1SHA4IJyiyNobDOrQzFFXy" title="Animal Spirits" artist="Vulfpeck" >}} +{{< music "8893154c-4e9d-44d3-8c77-3db17365ca76" >}} diff --git a/data/music/musicbrainz/8893154c-4e9d-44d3-8c77-3db17365ca76.json b/data/music/musicbrainz/8893154c-4e9d-44d3-8c77-3db17365ca76.json new file mode 100644 index 00000000..2a47ec58 --- /dev/null +++ b/data/music/musicbrainz/8893154c-4e9d-44d3-8c77-3db17365ca76.json @@ -0,0 +1 @@ +{"artist":"Vulfpeck","title":"Animal Spirits","musicbrainz":"https://musicbrainz.org/recording/8893154c-4e9d-44d3-8c77-3db17365ca76","links":["https://open.spotify.com/track/1SHA4IJyiyNobDOrQzFFXy"]} diff --git a/data/music/musicbrainz/8a8909c0-291c-4643-977f-17d27f052154.json b/data/music/musicbrainz/8a8909c0-291c-4643-977f-17d27f052154.json new file mode 100644 index 00000000..d695c2c8 --- /dev/null +++ b/data/music/musicbrainz/8a8909c0-291c-4643-977f-17d27f052154.json @@ -0,0 +1 @@ +{"artist":"Blur","title":"Girls and Boys","musicbrainz":"https://musicbrainz.org/recording/8a8909c0-291c-4643-977f-17d27f052154","links":["https://open.spotify.com/track/5CeL9C3bsoe4yzYS1Qz8cw"]} diff --git a/tools/go.mod b/tools/go.mod index e0812749..a5e11226 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -6,8 +6,7 @@ require ( github.com/BurntSushi/toml v1.3.2 github.com/Davincible/goinsta/v3 v3.2.6 github.com/PuerkitoBio/goquery v1.9.1 - github.com/aws/smithy-go v1.20.2 - github.com/bmatcuk/doublestar v1.3.4 + github.com/agnivade/levenshtein v1.2.1 github.com/bmatcuk/doublestar/v4 v4.6.1 github.com/grokify/html-strip-tags-go v0.1.0 github.com/h2non/filetype v1.1.3 @@ -18,14 +17,12 @@ require ( github.com/mmcdole/gofeed v1.3.0 github.com/stretchr/testify v1.8.2 golang.org/x/image v0.15.0 - golang.org/x/net v0.21.0 golang.org/x/text v0.14.0 gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/andybalholm/cascadia v1.3.2 // indirect - github.com/benbjohnson/clock v1.1.0 // indirect github.com/bluesky-social/indigo v0.0.0-20230504025040-8915cccc3319 // indirect github.com/chromedp/cdproto v0.0.0-20220901095120-1a01299a2163 // indirect github.com/chromedp/chromedp v0.8.5 // indirect @@ -39,14 +36,12 @@ require ( github.com/google/uuid v1.3.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-hclog v0.9.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.2 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/ipfs/bbloom v0.0.4 // indirect github.com/ipfs/go-block-format v0.1.2 // indirect github.com/ipfs/go-cid v0.4.0 // indirect github.com/ipfs/go-datastore v0.6.0 // indirect - github.com/ipfs/go-detect-race v0.0.1 // indirect github.com/ipfs/go-ipfs-blockstore v1.3.0 // indirect github.com/ipfs/go-ipfs-ds-help v1.1.0 // indirect github.com/ipfs/go-ipfs-util v0.0.2 // indirect @@ -58,11 +53,7 @@ require ( github.com/jbenet/goprocess v0.1.4 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect - github.com/kr/pretty v0.2.0 // indirect - github.com/kr/text v0.1.0 // indirect - github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/minio/sha256-simd v1.0.0 // indirect @@ -76,24 +67,19 @@ require ( github.com/multiformats/go-multihash v0.2.1 // indirect github.com/multiformats/go-varint v0.0.7 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect - github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect - github.com/smartystreets/assertions v1.2.0 // indirect - github.com/smartystreets/goconvey v1.7.2 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect - github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 // indirect github.com/whyrusleeping/cbor-gen v0.0.0-20230331140348-1f892b517e70 // indirect go.uber.org/atomic v1.10.0 // indirect - go.uber.org/goleak v1.1.11 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.24.0 // indirect golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect golang.org/x/sys v0.17.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.1.7 // indirect ) diff --git a/tools/go.sum b/tools/go.sum index 2712c181..5ebd2099 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -6,18 +6,17 @@ github.com/Davincible/goinsta/v3 v3.2.6 h1:+lNIWU6NABWd2VSGe83UQypnef+kzWwjmfgGi github.com/Davincible/goinsta/v3 v3.2.6/go.mod h1:jIDhrWZmttL/gtXj/mkCaZyeNdAAqW3UYjasOUW0YEw= github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= -github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= -github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bluesky-social/indigo v0.0.0-20230504025040-8915cccc3319 h1:VCNXRXpgyK3xkaQ8fzL5WzswerwLycke4B9ggLs1uOA= github.com/bluesky-social/indigo v0.0.0-20230504025040-8915cccc3319/go.mod h1:Hc09SUJXAIujaAvq7JXxi8ZQQI887grzPkHgn4JyE1Q= -github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= -github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/brianvoe/gofakeit/v6 v6.20.2/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= @@ -34,6 +33,7 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= diff --git a/tools/music-normalizer/filter.go b/tools/music-normalizer/filter.go new file mode 100644 index 00000000..ee72051d --- /dev/null +++ b/tools/music-normalizer/filter.go @@ -0,0 +1,26 @@ +package main + +import ( + "github.com/agnivade/levenshtein" +) + +func filter[T interface{}](items []T, filters ...func(T) bool) []T { + var allowed []T + for _, item := range items { + for _, filter := range filters { + if filter(item) { + allowed = append(allowed, item) + } + } + } + return allowed +} + +func hasCorrectDetails(trackName, artistName string) func(mbRecording) bool { + return func(mr mbRecording) bool { + dt := levenshtein.ComputeDistance(trackName, mr.Title) + da := levenshtein.ComputeDistance(artistName, mr.ArtistCredit[0].Name) + + return dt+da < 6 + } +} diff --git a/tools/music-normalizer/main.go b/tools/music-normalizer/main.go new file mode 100644 index 00000000..1f6e4324 --- /dev/null +++ b/tools/music-normalizer/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" +) + +// Spotify API credentials (set as environment variables) +var spotifyClientID = os.Getenv("SPOTIFY_CLIENT_ID") +var spotifyClientSecret = os.Getenv("SPOTIFY_CLIENT_SECRET") + +// Structs to parse Spotify API responses +type SpotifyAuthResponse struct { + AccessToken string `json:"access_token"` +} + +type SpotifyTrackResponse struct { + Name string `json:"name"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` +} + +func getSpotifyAccessToken() (string, error) { + authURL := "https://accounts.spotify.com/api/token" + data := url.Values{} + data.Set("grant_type", "client_credentials") + + req, _ := http.NewRequest("POST", authURL, strings.NewReader(data.Encode())) + req.SetBasicAuth(spotifyClientID, spotifyClientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var authResp SpotifyAuthResponse + json.Unmarshal(body, &authResp) + + return authResp.AccessToken, nil +} + +func getSpotifyTrackMetadata(trackID string) (string, string, error) { + token, err := getSpotifyAccessToken() + if err != nil { + return "", "", err + } + + url := fmt.Sprintf("https://api.spotify.com/v1/tracks/%s", trackID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var trackResp SpotifyTrackResponse + json.Unmarshal(body, &trackResp) + + if len(trackResp.Artists) == 0 { + return "", "", fmt.Errorf("no artist found for the track") + } + + return trackResp.Name, trackResp.Artists[0].Name, nil +} + +func check(err error) { + if err != nil { + panic(err) + } +} + +type musicbrainzData struct { + ID string `json:"-"` + Composer string `json:"composer,omitempty"` + Artist string `json:"artist,omitempty"` + Title string `json:"title"` + URL string `json:"musicbrainz"` + Links []string `json:"links"` +} + +func fromSpotifyTrack(trackID string) (musicbrainzData, error) { + trackName, artistName, err := getSpotifyTrackMetadata(trackID) + if err != nil { + return musicbrainzData{}, err + } + + fmt.Printf("Searching for %s - %s\n", trackName, artistName) + mb, err := searchMusicbrainz(trackName, artistName) + if err != nil { + return mb, err + } + + mb.Links = []string{"https://open.spotify.com/track/" + trackID} + + return mb, nil +} + +func main() { + if len(os.Args) < 2 { + fmt.Printf("Usage: %s \n", os.Args[0]) + os.Exit(1) + } + + u, err := url.Parse(os.Args[1]) + check(err) + + var mb musicbrainzData + switch { + case u.Host == "open.spotify.com" && strings.HasPrefix(u.Path, "/track/"): + mb, err = fromSpotifyTrack(u.Path[7:]) + default: + err = fmt.Errorf("unknown URL: %s", u) + } + check(err) + + f, err := os.OpenFile("./data/music/musicbrainz/"+mb.ID+".json", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + check(err) + + check(json.NewEncoder(f).Encode(mb)) +} diff --git a/tools/music-normalizer/musicbrainz.go b/tools/music-normalizer/musicbrainz.go new file mode 100644 index 00000000..21941872 --- /dev/null +++ b/tools/music-normalizer/musicbrainz.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +func searchMusicbrainz(trackName, artistName string) (musicbrainzData, error) { + var mb musicbrainzData + query := url.QueryEscape(fmt.Sprintf("title:\"%s\" AND artist:\"%s\"", trackName, artistName)) + searchURL := fmt.Sprintf("https://musicbrainz.org/ws/2/recording/?query=%s&method=advanced&limit=50&fmt=json", query) + + resp, err := http.Get(searchURL) + if err != nil { + return mb, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return mb, err + } + + var res mbResponse + if err := json.Unmarshal(body, &res); err != nil { + return mb, err + } + if len(res.Recordings) == 0 { + return mb, fmt.Errorf("no results found on MusicBrainz") + } + + recs := filter(res.Recordings, + hasCorrectDetails(trackName, artistName), + ) + + sort.Sort(ByDesirability(recs)) + + mb.ID = recs[0].ID + mb.URL = "https://musicbrainz.org/recording/" + recs[0].ID + mb.Title = recs[0].Title + mb.Artist = recs[0].ArtistCredit[0].Name + + return mb, nil +} + +func stringIs(str string, has ...string) bool { + t := strings.ToLower(str) + for _, ha := range has { + if strings.Contains(t, ha) { + return true + } + } + return false +} + +func parseReleaseDate(str string) time.Time { + if t, err := time.Parse("2006-01-02", str); err == nil { + return t + } + + if t, err := time.Parse("2006", str); err == nil { + // Go to the last day of the year, as we want more specific dates to take precedence + return t.AddDate(1, 0, -1) + } + + return time.Time{} +} diff --git a/tools/music-normalizer/sort.go b/tools/music-normalizer/sort.go new file mode 100644 index 00000000..1cf564b3 --- /dev/null +++ b/tools/music-normalizer/sort.go @@ -0,0 +1,125 @@ +package main + +import ( + "strings" +) + +type mbResponse struct { + Recordings []mbRecording +} + +type mbRecording struct { + ID string + Score int + Title string + Disambiguation string + FirstRelease string `json:"first-release-date"` + ArtistCredit []mbArtist `json:"artist-credit"` + Releases []mbRelease `json:"releases"` +} + +type mbArtist struct { + Name string `json:"name"` +} + +type mbRelease struct { + ID string + Score int + Title string + Country string + Status string + Quality string + ArtistCredit []mbArtist `json:"artist-credit"` + Date string `json:"first-release-date"` +} + +type ByDesirability []mbRecording + +func (r ByDesirability) Len() int { return len(r) } +func (r ByDesirability) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r ByDesirability) Less(i, j int) bool { + return sortInTurn(r[i], r[j], + // highScoreFirst, + preferGoodRelease, + preferNonLiveRecordings, + earliestRelease, + preferShorterName, + deterministicallyByID, + ) +} + +func sortInTurn[T interface{}](a, b T, comparators ...func(T, T) int) bool { + for _, comparator := range comparators { + switch comparator(a, b) { + case -1: + return true + case 1: + return false + default: + // Try the next comparator + } + } + // Don't swap if they're identical + return false +} + +func ternary(first, last bool) int { + if first { + return -1 + } else if last { + return 1 + } else { + return 0 + } +} + +func highScoreFirst(a, b mbRecording) int { + as := a.Score / 20 + bs := b.Score / 20 + return ternary(as > bs, as < bs) +} + +func preferNonLiveRecordings(a, b mbRecording) int { + al := stringIs(a.Disambiguation, "live") + bl := stringIs(b.Disambiguation, "live") + return ternary(!al && bl, al && !bl) +} + +func preferGoodRelease(a, b mbRecording) int { + filter := func(r mbRelease) bool { + if len(r.ArtistCredit) > 0 && stringIs(r.ArtistCredit[0].Name, "various") { + return false + } + if !stringIs(r.Status, "official") { + return false + } + return true + } + + ar := filterReleases(a, filter) + br := filterReleases(a, filter) + return ternary(!ar && br, ar && !br) +} + +func preferShorterName(a, b mbRecording) int { + return ternary(len(a.Title) < len(b.Title), len(a.Title) > len(b.Title)) +} + +func filterReleases(r mbRecording, filter func(mbRelease) bool) bool { + for _, rel := range r.Releases { + if filter(rel) { + return true + } + } + return false +} + +func earliestRelease(a, b mbRecording) int { + ad := parseReleaseDate(a.FirstRelease) + bd := parseReleaseDate(b.FirstRelease) + return ternary(ad.Before(bd), ad.After(bd)) +} + +func deterministicallyByID(a, b mbRecording) int { + return strings.Compare(a.ID, b.ID) +}