├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── apple-music.go ├── go.mod ├── go.sum ├── main.go └── metadata.go /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 2 | 3 | release: 4 | footer: | 5 | **Full Changelog**: https://github.com/caarlos0/discord-applemusic-rich-presence/compare/{{ .PreviousTag }}...{{ .Tag }} 6 | 7 | --- 8 | 9 | _Released with [GoReleaser Pro](https://goreleaser.com/pro)!_ 10 | brews: 11 | - name: discord-applemusic-rich-presence 12 | repository: 13 | owner: caarlos0 14 | name: homebrew-tap 15 | directory: Formula 16 | description: "Apple Music Rich Presence for Discord" 17 | homepage: "https://caarlos0.dev" 18 | service: | 19 | run [opt_bin/"discord-applemusic-rich-presence"] 20 | keep_alive true 21 | log_path var/"log/discord-applemusic-rich-presence.log" 22 | error_log_path var/"log/discord-applemusic-rich-presence.log" 23 | 24 | builds: 25 | - id: discord-applemusic-rich-presence 26 | goos: 27 | - darwin 28 | goarch: 29 | - amd64 30 | - arm64 31 | mod_timestamp: "{{ .CommitTimestamp }}" 32 | ldflags: 33 | - -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }} -X main.date={{ .CommitDate }} -X main.builtBy=goreleaser 34 | flags: 35 | - -trimpath 36 | env: 37 | - CGO_ENABLED=1 38 | archives: 39 | - id: default 40 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' 41 | format: tar.gz 42 | files: 43 | - src: license* 44 | - src: LICENSE* 45 | - src: readme* 46 | - src: README* 47 | - src: changelog* 48 | - src: CHANGELOG* 49 | changelog: 50 | filters: 51 | exclude: 52 | - "^test:" 53 | - ^chore 54 | - merge conflict 55 | - Merge pull request 56 | - Merge remote-tracking branch 57 | - Merge branch 58 | - go mod tidy 59 | sort: asc 60 | use: github 61 | groups: 62 | - title: Dependency updates 63 | regexp: ^.*feat\(deps\)*:+.*$ 64 | order: 300 65 | - title: New Features 66 | regexp: ^.*feat[(\w)]*:+.*$ 67 | order: 100 68 | - title: Bug fixes 69 | regexp: ^.*fix[(\w)]*:+.*$ 70 | order: 200 71 | - title: Documentation updates 72 | regexp: ^.*docs[(\w)]*:+.*$ 73 | order: 400 74 | - title: Other work 75 | order: 9999 76 | before: 77 | hooks: 78 | - cmd: go mod tidy 79 | gomod: 80 | proxy: true 81 | gobinary: go 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Carlos A Becker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discord's Rich Presence from Apple Music 2 | 3 | This is a simple binary that uses Apple Script to grab the current song being 4 | played on Apple Music, and reports it as Discord Rich Presence. 5 | 6 | You can leave it running "forever", and it should work in a loop. 7 | 8 | ## Install 9 | 10 | To use it, simply install it with: 11 | 12 | ```sh 13 | brew install caarlos0/tap/discord-applemusic-rich-presence 14 | ``` 15 | 16 | ## Run 17 | 18 | And then start and enable the service with: 19 | 20 | ```sh 21 | brew services start caarlos0/tap/discord-applemusic-rich-presence 22 | ``` 23 | 24 | And that should do the trick 😃 25 | 26 | ## F.A.Q. 27 | 28 | ### How it looks like? 29 | 30 | It looks more or less like this: 31 | 32 | ![Screenshot](https://user-images.githubusercontent.com/245435/201494021-4b75aa4b-fb59-4a36-9ee5-c2d6ebae627d.png) 33 | 34 | 35 | ### Can it look more like the Spotify integration? 36 | 37 | No. Nothing I can do, AFAIK, it's a Discord limitation. 38 | 39 | ### Clicking in "Search in Apple Music" does not work... 40 | 41 | Apparently... you can't click in buttons in your own Rich Presence. 42 | Ask a friend to click on yours to see if it is really not working. 43 | 44 | ### Nothing happens... 45 | 46 | Sometimes you'd need to restart the service and/or Discord. 47 | No idea why, haven't catch a single error about it, it just stops working. 48 | 49 | To restart: 50 | 51 | ```sh 52 | brew services restart caarlos0/tap/discord-applemusic-rich-presence 53 | ``` 54 | 55 | ### Where are the logs? 56 | 57 | ```sh 58 | tail -f $(brew --prefix)/var/log/discord-applemusic-rich-presence.log 59 | ``` 60 | 61 | --- 62 | 63 | ###### Hat tip to: 64 | 65 | - https://github.com/AB-Law/Apple-Music-Discord-Rich-Presence 66 | - https://github.com/rohilpatel1/Apple-Music-Rich-Presence 67 | 68 | And many other projects that do the same thing. 69 | 70 | -------------------------------------------------------------------------------- /apple-music.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/caarlos0/log" 11 | "github.com/cheshir/ttlcache" 12 | ) 13 | 14 | func tellMusic(s string) (string, error) { 15 | bts, err := exec.Command( 16 | "osascript", 17 | "-e", "tell application \"Music\"", 18 | "-e", s, 19 | "-e", "end tell", 20 | ).CombinedOutput() 21 | if err != nil { 22 | return "", fmt.Errorf("%s: %w", strings.TrimSpace(string(bts)), err) 23 | } 24 | return strings.TrimSpace(string(bts)), nil 25 | } 26 | 27 | func getNowPlaying() (Details, error) { 28 | init := time.Now() 29 | defer func() { 30 | log.WithField("took", time.Since(init)).Info("got info") 31 | }() 32 | 33 | initialState, err := tellMusic("get {database id} of current track & {player position, player state}") 34 | if err != nil { 35 | return Details{}, err 36 | } 37 | 38 | songID, err := strconv.ParseInt(strings.Split(initialState, ", ")[0], 10, 64) 39 | if err != nil { 40 | return Details{}, err 41 | } 42 | 43 | position, err := strconv.ParseFloat(strings.Split(initialState, ", ")[1], 64) 44 | if err != nil { 45 | return Details{}, err 46 | } 47 | 48 | state := strings.Split(initialState, ", ")[2] 49 | if state != statePlaying { 50 | return Details{ 51 | State: state, 52 | }, nil 53 | } 54 | 55 | cached, cachedOk := cache.song.Get(ttlcache.Int64Key(songID)) 56 | if cachedOk { 57 | log.WithField("songID", songID).Debug("got song from cache") 58 | return Details{ 59 | Song: cached.(Song), 60 | Position: position, 61 | State: state, 62 | }, nil 63 | } 64 | 65 | name, err := tellMusic("get {name} of current track") 66 | if err != nil { 67 | return Details{}, err 68 | } 69 | artist, err := tellMusic("get {artist} of current track") 70 | if err != nil { 71 | return Details{}, err 72 | } 73 | album, err := tellMusic("get {album} of current track") 74 | if err != nil { 75 | return Details{}, err 76 | } 77 | yearDuration, err := tellMusic("get {year, duration} of current track") 78 | if err != nil { 79 | return Details{}, err 80 | } 81 | 82 | year, err := strconv.Atoi(strings.Split(yearDuration, ", ")[0]) 83 | if err != nil { 84 | return Details{}, err 85 | } 86 | 87 | duration, err := strconv.ParseFloat(strings.Split(yearDuration, ", ")[1], 64) 88 | if err != nil { 89 | return Details{}, err 90 | } 91 | 92 | metadata, err := getMetadata(artist, album, name) 93 | if err != nil { 94 | return Details{}, err 95 | } 96 | 97 | song := Song{ 98 | ID: songID, 99 | Name: name, 100 | Artist: artist, 101 | Album: album, 102 | Year: year, 103 | Duration: duration, 104 | AlbumArtwork: metadata.AlbumArtwork, 105 | ArtistArtwork: metadata.ArtistArtwork, 106 | ShareURL: metadata.ShareURL, 107 | ShareID: metadata.ID, 108 | } 109 | 110 | cache.song.Set(ttlcache.Int64Key(songID), song, time.Hour) 111 | 112 | return Details{ 113 | Song: song, 114 | Position: position, 115 | State: state, 116 | }, nil 117 | } 118 | 119 | type Details struct { 120 | Song Song 121 | Position float64 122 | State string 123 | } 124 | 125 | type Song struct { 126 | ID int64 127 | Name string 128 | Artist string 129 | Album string 130 | Year int 131 | Duration float64 132 | AlbumArtwork string 133 | ArtistArtwork string 134 | ShareURL string 135 | ShareID string 136 | } 137 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/caarlos0/discord-applemusic-rich-presence 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/caarlos0/log v0.1.10 7 | github.com/cheshir/ttlcache v1.0.0 8 | github.com/hugolgst/rich-go v0.0.0-20210925091458-d59fb695d9c0 9 | ) 10 | 11 | require ( 12 | github.com/aymanbagabas/go-osc52 v1.2.1 // indirect 13 | github.com/charmbracelet/lipgloss v0.6.0 // indirect 14 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 15 | github.com/mattn/go-isatty v0.0.16 // indirect 16 | github.com/mattn/go-runewidth v0.0.14 // indirect 17 | github.com/muesli/reflow v0.3.0 // indirect 18 | github.com/muesli/termenv v0.13.0 // indirect 19 | github.com/rivo/uniseg v0.4.2 // indirect 20 | golang.org/x/sys v0.1.0 // indirect 21 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 2 | github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E= 3 | github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 4 | github.com/caarlos0/log v0.1.10 h1:kHKiXTKEeK019o7QQWXRbHVKFrYYljxuQ7vF2taEA3M= 5 | github.com/caarlos0/log v0.1.10/go.mod h1:BLxpdZKXvWBjB6fshua4c8d7ApdYjypEDok6ibt+pXk= 6 | github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= 7 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 8 | github.com/cheshir/ttlcache v1.0.0 h1:SAjXm+6LdzJxzeNK5qIUq8e+nOI3DQgpe6KZiOx7oJA= 9 | github.com/cheshir/ttlcache v1.0.0/go.mod h1:B9qWHhPE7FnRG2HNiPajGzOFX9NYcObDTkg3Ixh9Fzk= 10 | github.com/hugolgst/rich-go v0.0.0-20210925091458-d59fb695d9c0 h1:IkZfZBWufGFLvci1vXLiUu8PWVtG6wlz920CMtHobMo= 11 | github.com/hugolgst/rich-go v0.0.0-20210925091458-d59fb695d9c0/go.mod h1:nGaW7CGfNZnhtiFxMpc4OZdqIexGXjUlBnlmpZmjEKA= 12 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 13 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 14 | github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= 15 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 16 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 17 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 18 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 19 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 20 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 21 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 22 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 23 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 24 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 25 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 26 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 27 | github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0= 28 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 29 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 30 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 31 | github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= 32 | github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 33 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 | golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= 36 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= 38 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= 39 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | "time" 9 | 10 | "github.com/caarlos0/log" 11 | "github.com/cheshir/ttlcache" 12 | "github.com/hugolgst/rich-go/client" 13 | ) 14 | 15 | const statePlaying = "playing" 16 | 17 | var ( 18 | shortSleep = 5 * time.Second 19 | longSleep = time.Minute 20 | cache = Cache{} 21 | ) 22 | 23 | type Cache struct { 24 | song *ttlcache.Cache 25 | albumArtwork *ttlcache.Cache 26 | artistArtwork *ttlcache.Cache 27 | shareURL *ttlcache.Cache 28 | } 29 | 30 | func main() { 31 | cache = Cache{ 32 | song: ttlcache.New(time.Minute), 33 | albumArtwork: ttlcache.New(time.Minute), 34 | artistArtwork: ttlcache.New(time.Minute), 35 | shareURL: ttlcache.New(time.Minute), 36 | } 37 | defer func() { 38 | _ = cache.song.Close() 39 | _ = cache.albumArtwork.Close() 40 | _ = cache.artistArtwork.Close() 41 | _ = cache.shareURL.Close() 42 | }() 43 | 44 | log.SetLevelFromString("warning") 45 | if level := os.Getenv("LOG_LEVEL"); level != "" { 46 | log.SetLevelFromString(level) 47 | } 48 | ac := activityConnection{} 49 | defer func() { ac.stop() }() 50 | 51 | for { 52 | if !isRunning("MacOS/Music") { 53 | log.WithField("sleep", longSleep).Warn("Apple Music is not running") 54 | ac.stop() 55 | time.Sleep(longSleep) 56 | continue 57 | } 58 | if !(isRunning("MacOS/Discord") || isRunning("arrpc")) { 59 | log.WithField("sleep", longSleep).Warn("Discord is not running") 60 | ac.stop() 61 | time.Sleep(longSleep) 62 | continue 63 | } 64 | details, err := getNowPlaying() 65 | if err != nil { 66 | if strings.Contains(err.Error(), "(-1728)") { 67 | log.WithField("sleep", longSleep).Warn("Apple Music stopped running") 68 | ac.stop() 69 | time.Sleep(longSleep) 70 | continue 71 | } 72 | 73 | log.WithError(err).WithField("sleep", shortSleep).Error("will try again soon") 74 | ac.stop() 75 | time.Sleep(shortSleep) 76 | continue 77 | } 78 | 79 | if details.State != statePlaying { 80 | if ac.connected { 81 | log.Info("not playing") 82 | ac.stop() 83 | } 84 | time.Sleep(shortSleep) 85 | continue 86 | } 87 | 88 | if err := ac.play(details); err != nil { 89 | log.WithError(err).Warn("could not set activity, will retry later") 90 | } 91 | 92 | time.Sleep(shortSleep) 93 | } 94 | } 95 | 96 | func isRunning(app string) bool { 97 | bts, err := exec.Command("pgrep", "-f", app).CombinedOutput() 98 | return string(bts) != "" && err == nil 99 | } 100 | 101 | type activityConnection struct { 102 | connected bool 103 | lastSongID int64 104 | lastPosition float64 105 | } 106 | 107 | func (ac *activityConnection) stop() { 108 | if ac.connected { 109 | client.Logout() 110 | ac.connected = false 111 | ac.lastPosition = 0.0 112 | ac.lastSongID = 0 113 | } 114 | } 115 | 116 | func (ac *activityConnection) play(details Details) error { 117 | song := details.Song 118 | if ac.lastSongID == song.ID { 119 | if details.Position >= ac.lastPosition { 120 | log. 121 | WithField("songID", song.ID). 122 | WithField("position", details.Position). 123 | Debug("ongoing activity, ignoring") 124 | return nil 125 | } 126 | } 127 | log. 128 | WithField("lastSongID", ac.lastSongID). 129 | WithField("songID", song.ID). 130 | WithField("lastPosition", ac.lastPosition). 131 | WithField("position", details.Position). 132 | Debug("new event") 133 | 134 | ac.lastPosition = details.Position 135 | ac.lastSongID = song.ID 136 | 137 | start := time.Now().Add(-1 * time.Duration(details.Position) * time.Second) 138 | if !ac.connected { 139 | if err := client.Login("861702238472241162"); err != nil { 140 | log.WithError(err).Fatal("could not create rich presence client") 141 | } 142 | ac.connected = true 143 | } 144 | 145 | var buttons []*client.Button 146 | if song.ShareURL != "" { 147 | buttons = append(buttons, &client.Button{ 148 | Label: "Listen on Apple Music", 149 | Url: song.ShareURL, 150 | }) 151 | } 152 | if link := songlink(song); link != "" { 153 | buttons = append(buttons, &client.Button{ 154 | Label: "View on SongLink", 155 | Url: link, 156 | }) 157 | } 158 | 159 | if err := client.SetActivity(client.Activity{ 160 | State: fmt.Sprintf("by %s (%s)", song.Artist, song.Album), 161 | Details: song.Name, 162 | LargeImage: firstNonEmpty(song.AlbumArtwork, "applemusic"), 163 | SmallImage: firstNonEmpty(song.ArtistArtwork, "play"), 164 | LargeText: song.Album, 165 | SmallText: song.Artist, 166 | Timestamps: &client.Timestamps{ 167 | Start: &start, 168 | }, 169 | Buttons: buttons, 170 | }); err != nil { 171 | return err 172 | } 173 | 174 | log.WithField("song", song.Name). 175 | WithField("album", song.Album). 176 | WithField("artist", song.Artist). 177 | WithField("year", song.Year). 178 | WithField("duration", time.Duration(song.Duration)*time.Second). 179 | WithField("position", time.Duration(details.Position)*time.Second). 180 | WithField("songlink", songlink(song)). 181 | Warn("now playing") 182 | return nil 183 | } 184 | 185 | func songlink(song Song) string { 186 | if song.ShareID == "" { 187 | return "" 188 | } 189 | return fmt.Sprintf("https://song.link/i/%s", song.ShareID) 190 | } 191 | 192 | func firstNonEmpty(ss ...string) string { 193 | for _, s := range ss { 194 | if s != "" { 195 | return s 196 | } 197 | } 198 | return "" 199 | } 200 | -------------------------------------------------------------------------------- /metadata.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | "time" 10 | 11 | "github.com/caarlos0/log" 12 | "github.com/cheshir/ttlcache" 13 | ) 14 | 15 | type SongMetadata struct { 16 | ID string 17 | AlbumArtwork string 18 | ShareURL string 19 | } 20 | 21 | const baseURL = "https://tools.applemediaservices.com/api/apple-media/music/US/search.json" 22 | 23 | func get(url string, result interface{}) error { 24 | resp, err := http.Get(url) 25 | if err != nil { 26 | return err 27 | } 28 | defer resp.Body.Close() 29 | 30 | bts, err := io.ReadAll(resp.Body) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | if err := json.Unmarshal(bts, result); err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func getSongMetadata(key string) (SongMetadata, error) { 43 | var result getSongMetadataResult 44 | err := get(baseURL+"?types=songs&limit=1&term="+key, &result) 45 | if err != nil { 46 | return SongMetadata{}, err 47 | } 48 | 49 | if len(result.Songs.Data) == 0 { 50 | return SongMetadata{}, nil 51 | } 52 | 53 | id := result.Songs.Data[0].ID 54 | artwork := result.Songs.Data[0].Attributes.Artwork.URL 55 | artwork = strings.Replace(artwork, "{w}", "512", 1) 56 | artwork = strings.Replace(artwork, "{h}", "512", 1) 57 | 58 | return SongMetadata{ 59 | ID: id, 60 | AlbumArtwork: artwork, 61 | ShareURL: result.Songs.Data[0].Attributes.URL, 62 | }, nil 63 | } 64 | 65 | func getArtistArtwork(key string) (string, error) { 66 | var result getArtistMetadataResult 67 | err := get(baseURL+"?types=artists&limit=1&term="+key, &result) 68 | if err != nil { 69 | return "", err 70 | } 71 | 72 | if len(result.Artists.Data) == 0 { 73 | return "", nil 74 | } 75 | 76 | artwork := result.Artists.Data[0].Attributes.Artwork.URL 77 | artwork = strings.Replace(artwork, "{w}", "512", 1) 78 | artwork = strings.Replace(artwork, "{h}", "512", 1) 79 | 80 | return artwork, nil 81 | } 82 | 83 | func getMetadata(artist, album, song string) (Metadata, error) { 84 | key := url.QueryEscape(strings.Join([]string{artist, album, song}, " ")) 85 | 86 | albumArtworkCached, albumArtworkOk := cache.albumArtwork.Get(ttlcache.StringKey(key)) 87 | shareURLCached, shareURLOk := cache.shareURL.Get(ttlcache.StringKey(key)) 88 | 89 | artistArtworkCached, artistArtworkOk := cache.artistArtwork.Get(ttlcache.StringKey(artist)) 90 | 91 | if albumArtworkOk && artistArtworkOk && shareURLOk { 92 | log.WithField("key", key).Debug("got song info from cache") 93 | return Metadata{ 94 | AlbumArtwork: albumArtworkCached.(string), 95 | ArtistArtwork: artistArtworkCached.(string), 96 | ShareURL: shareURLCached.(string), 97 | }, nil 98 | } 99 | 100 | var err error 101 | var songMetadata SongMetadata 102 | var artistArtwork string 103 | 104 | if albumArtworkOk && shareURLOk { 105 | songMetadata = SongMetadata{ 106 | AlbumArtwork: albumArtworkCached.(string), 107 | ShareURL: shareURLCached.(string), 108 | } 109 | } else { 110 | log.WithField("song", song).Debug("getting song metadata from api") 111 | songMetadata, err = getSongMetadata(key) 112 | if err != nil { 113 | return Metadata{}, err 114 | } 115 | } 116 | 117 | if artistArtworkOk { 118 | artistArtwork = artistArtworkCached.(string) 119 | } else { 120 | log.WithField("artist", artist).Debug("getting artist artwork from api") 121 | artistArtwork, _ = getArtistArtwork(url.QueryEscape(artist)) 122 | } 123 | 124 | cache.albumArtwork.Set(ttlcache.StringKey(key), songMetadata.AlbumArtwork, time.Hour) 125 | cache.shareURL.Set(ttlcache.StringKey(key), songMetadata.ShareURL, time.Hour) 126 | 127 | cache.artistArtwork.Set(ttlcache.StringKey(artist), artistArtwork, time.Hour) 128 | 129 | return Metadata{ 130 | ID: songMetadata.ID, 131 | AlbumArtwork: songMetadata.AlbumArtwork, 132 | ShareURL: songMetadata.ShareURL, 133 | ArtistArtwork: artistArtwork, 134 | }, nil 135 | } 136 | 137 | type getSongMetadataResult struct { 138 | Songs struct { 139 | Data []struct { 140 | ID string `json:"id"` 141 | Attributes struct { 142 | URL string `json:"url"` 143 | Artwork struct { 144 | URL string `json:"url"` 145 | } `json:"artwork"` 146 | } `json:"attributes"` 147 | } `json:"data"` 148 | } `json:"songs"` 149 | } 150 | 151 | type getArtistMetadataResult struct { 152 | Artists struct { 153 | Data []struct { 154 | Attributes struct { 155 | Artwork struct { 156 | URL string `json:"url"` 157 | } `json:"artwork"` 158 | } `json:"attributes"` 159 | } `json:"data"` 160 | } `json:"artists"` 161 | } 162 | 163 | type Metadata struct { 164 | ID string 165 | AlbumArtwork string 166 | ArtistArtwork string 167 | ShareURL string 168 | } 169 | --------------------------------------------------------------------------------