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)
+}