chore: apple music post

This commit is contained in:
Cory Dransfeldt 2023-06-21 14:31:04 -07:00
parent 3215450a26
commit bfc3db2a24
No known key found for this signature in database
16 changed files with 299 additions and 51 deletions

View file

@ -1,18 +0,0 @@
name: Manual Vercel build
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on: [workflow_dispatch]
jobs:
cron:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}

View file

@ -9,6 +9,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: POST to Build Hook
env:
BUILD_KEY: ${{ secrets.NETLIFY_BUILD_KEY }}
run: curl -X POST -d {} https://api.netlify.com/build_hooks/$env:BUILD_KEY
run: curl -X POST -d {} https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_BUILD_KEY }}?trigger_branch=main&trigger_title=Scheduled+Github+build&clear_cache=true

View file

@ -12,10 +12,10 @@ This is the code for my personal website and portfolio. Built using [11ty](https
## My latest posts
<!-- BLOGPOSTS:START -->
- [Displaying listening data from Apple Music using MusicKit.js](https://coryd.dev/posts/2023/displaying-listening-data-from-apple-music-using-musickit/)
- [Support small businesses (internet ones too)](https://coryd.dev/posts/2023/support-small-businesses-internet-ones-too/)
- [From ICS to JSON: surfacing anticipated albums](https://coryd.dev/posts/2023/from-ics-to-json-surfacing-anticipated-albums/)
- [Optimizing for performance with Eleventy](https://coryd.dev/posts/2023/optimizing-for-performance-with-eleventy/)
- [Domain names as discoverable personal identifiers for the web](https://coryd.dev/posts/2023/domain-names-as-discoverable-personal-identifiers-for-the-web/)
- [I block ads](https://coryd.dev/posts/2023/i-block-ads/)
<!-- BLOGPOSTS:END -->

View file

@ -35,3 +35,7 @@
# netlify app domain
https://cdme.netlify.app https://coryd.dev 301!
# analytics
/api/send https://analytics.umami.is/api/send 200!
/analytics.js https://analytics.umami.is/script.js 200!

View file

@ -1,3 +1,3 @@
{
"timestamp": 1687363388332
"timestamp": 1687536186331
}

View file

@ -4352,5 +4352,68 @@
"https://social.lol/users/cory/statuses/110583047012640568"
],
"lastTootTimestamp": 1687363388329
},
"https://coryd.dev/posts/2023/displaying-listening-data-from-apple-music-using-musickit/": {
"id": "https://coryd.dev/posts/2023/displaying-listening-data-from-apple-music-using-musickit/",
"title": "📝: Displaying listening data from Apple Music using MusicKit.js",
"url": "https://coryd.dev/posts/2023/displaying-listening-data-from-apple-music-using-musickit/",
"content_text": "📝: Displaying listening data from Apple Music using MusicKit.js https://coryd.dev/posts/2023/displaying-listening-data-from-apple-music-using-musickit/",
"date_published": "2023-06-21T00:00:00-08:00",
"toots": [
"https://social.lol/users/cory/statuses/110585489118367775"
],
"lastTootTimestamp": 1687400651881
},
"https://www.techdirt.com/2023/06/21/seven-rules-for-internet-ceos-to-avoid-enshittification/": {
"id": "https://www.techdirt.com/2023/06/21/seven-rules-for-internet-ceos-to-avoid-enshittification/",
"title": "🔗: Seven Rules For Internet CEOs To Avoid Enshittification",
"url": "https://www.techdirt.com/2023/06/21/seven-rules-for-internet-ceos-to-avoid-enshittification/",
"content_text": "🔗: Seven Rules For Internet CEOs To Avoid Enshittification https://www.techdirt.com/2023/06/21/seven-rules-for-internet-ceos-to-avoid-enshittification/",
"date_published": "2023-06-21T00:00:00-08:00",
"toots": [
"https://social.lol/users/cory/statuses/110584967862279088"
],
"lastTootTimestamp": 1687392698129
},
"https://sanjosespotlight.com/full-report-silicon-valley-pain-index-2023/": {
"id": "https://sanjosespotlight.com/full-report-silicon-valley-pain-index-2023/",
"title": "🔗: Full report: Silicon Valley Pain Index 2023",
"url": "https://sanjosespotlight.com/full-report-silicon-valley-pain-index-2023/",
"content_text": "🔗: Full report: Silicon Valley Pain Index 2023 https://sanjosespotlight.com/full-report-silicon-valley-pain-index-2023/",
"date_published": "2023-06-21T00:00:00-08:00",
"toots": [
"https://social.lol/users/cory/statuses/110585878945797189"
],
"lastTootTimestamp": 1687406600159
},
"https://blog.cassidoo.co/post/open-standards-are-good/": {
"id": "https://blog.cassidoo.co/post/open-standards-are-good/",
"title": "🔗: Open standards, trust, and Google",
"url": "https://blog.cassidoo.co/post/open-standards-are-good/",
"content_text": "🔗: Open standards, trust, and Google https://blog.cassidoo.co/post/open-standards-are-good/",
"date_published": "2023-06-23T00:00:00-08:00",
"toots": []
},
"https://thenewstack.io/too-much-javascript-why-the-frontend-needs-to-build-better/": {
"id": "https://thenewstack.io/too-much-javascript-why-the-frontend-needs-to-build-better/",
"title": "🔗: Too Much JavaScript? Why the Frontend Needs to Build Better",
"url": "https://thenewstack.io/too-much-javascript-why-the-frontend-needs-to-build-better/",
"content_text": "🔗: Too Much JavaScript? Why the Frontend Needs to Build Better https://thenewstack.io/too-much-javascript-why-the-frontend-needs-to-build-better/",
"date_published": "2023-06-22T00:00:00-08:00",
"toots": [
"https://social.lol/users/cory/statuses/110593898960867682"
],
"lastTootTimestamp": 1687528975877
},
"https://themarkup.org/privacy/2023/06/23/how-your-attention-is-auctioned-off-to-advertisers": {
"id": "https://themarkup.org/privacy/2023/06/23/how-your-attention-is-auctioned-off-to-advertisers",
"title": "🔗: How Your Attention Is Auctioned Off to Advertisers",
"url": "https://themarkup.org/privacy/2023/06/23/how-your-attention-is-auctioned-off-to-advertisers",
"content_text": "🔗: How Your Attention Is Auctioned Off to Advertisers https://themarkup.org/privacy/2023/06/23/how-your-attention-is-auctioned-off-to-advertisers",
"date_published": "2023-06-23T00:00:00-08:00",
"toots": [
"https://social.lol/users/cory/statuses/110594371504094526"
],
"lastTootTimestamp": 1687536186329
}
}

View file

@ -1,11 +1,6 @@
###
# PLUGINS
###
[[plugins]]
package = "netlify-plugin-cache"
[plugins.inputs]
paths = [ "./src/assets/img/cache" ]
[[plugins]]
package = "@netlify/plugin-lighthouse"
[plugins.inputs]

View file

@ -22,11 +22,10 @@
"eslint": "^8.42.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"netlify-plugin-cache": "^1.0.3",
"postcss": "^8.4.24",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"sanitize-html": "^2.10.0"
"sanitize-html": "^2.11.0"
},
"dependencies": {
"@11ty/eleventy-activity-feed": "^1.0.9",

View file

@ -63,6 +63,7 @@
<script type="application/ld+json">
{% jsonLd meta, type, tags %}
</script>
<script async src="{{ site.url }}/analytics.js" data-website-id="d8d37a0e-fa76-4406-ba67-938ccefdc56f"></script>
</head>
<body class="dark:text-white bg-white dark:bg-gray-900 font-sans text-gray-800">
{{ content }}

View file

@ -1,7 +1,7 @@
{% if site.coffee != "" %}
<a
href={{ site.coffee }}
onclick="va('event',{name:'Coffee',data:{location:'Header'}})"
onclick="umami.track('coffee', { name: 'Coffee', url: '{{ page.url }}' })"
rel="me"
title="Buy Me a Coffee">
<svg

View file

@ -2,7 +2,7 @@
{% for tag in tags %}
{% if tag != "posts" %}
<a href="/tags/{{ tag }}">
<span class="tag--button tag--button__small">{{ tag }}</span>
<div class="tag--button tag--button__small">{{ tag }}</div>
</a>
{% endif %}
{% endfor %}

View file

@ -0,0 +1,209 @@
---
date: '2023-06-21'
title: 'Displaying listening data from Apple Music using MusicKit.js'
draft: false
tags: ['development', 'music', 'Eleventy', 'Apple', 'JavaScript']
image: https://cdn.coryd.dev/blog/albums-artists.jpg
---
Up until now my [now](https://coryd.dev/now) page has sourced music data from Last.fm (and may well again). But, in the interest in experimenting a bit, I've tried my hand at rewriting that part of the page to leverage data from Apple Music, using [MusicKit.js](https://developer.apple.com/documentation/musickitjs) instead.<!-- excerpt -->
This implementation gets me away from needing a separate app installed to send my plays to Last.fm[^1]. It should be noted that this approach **does not** track or store your listening history. Depending on your attitude towards personal data collection this may be a feature or a deal-breaker.
With that said, let's get into it.
First, register a developer account with Apple (you'll need this to obtain the necessary api access). The exceedingly talented [Lee Martin](https://www.linkedin.com/in/leepaulmartin/) has [an excellent write-up on creating an Apple Music token that I referenced to obtain mine](https://leemartin.dev/creating-an-apple-music-api-token-e0e5067e4281). You'll need a Team Id, Private Key and Key Id. Follow Lee's instructions, [copy his script](https://gist.githubusercontent.com/leemartin/0dac81a74a58f8587270dca9089ddb7f/raw/8378ff9afe9ed841120c774813f81bd7fdf387fe/musickit-token-encoder.js)[^2]. Run `yarn add jsonwebtoken` and `node index.js`. Save the token that it writes to the buffer.
Second, we need a `music-user-token` to send in our request headers. This is obtained by authenticating with Apple Music (and lasts for about 6 months). We'll obtain this token using the following markup:
```html
<html>
<script src="https://js-cdn.music.apple.com/musickit/v1/musickit.js"></script>
<script>
document.addEventListener('musickitloaded', function () {
MusicKit.configure({
developerToken: '<REMEMBER THE TOKEN FROM STEP 1?>',
app: {
name: 'name',
build: '1'
}
});
const music = MusicKit.getInstance();
music.authorize().then(function (response) {
console.log(response);
});
});
</script>
</html>
```
Open this page in your browser, keep an eye on your console for the page and the popup that opens and `grep` around to find your `music-user-token` in the output. Save it.
Third (next?), we need to call the Apple Music endpoint and get our data. I'm doing this in Eleventy and the code looks like this:
```javascript
const { AssetCache } = require('@11ty/eleventy-fetch')
const sortTrim = (array, length = 8) =>
Object.values(array)
.sort((a, b) => b.plays - a.plays)
.splice(0, length)
module.exports = async function () {
const APPLE_BEARER = process.env.API_BEARER_APPLE_MUSIC
const APPLE_TOKEN = process.env.API_TOKEN_APPLE_MUSIC
const asset = new AssetCache('recent_tracks_data')
const PAGE_SIZE = 30
const PAGES = 8
const response = {
artists: {},
albums: {},
tracks: {},
}
let CURRENT_PAGE = 0
let res = []
if (asset.isCacheValid('1h')) return await asset.getCachedValue()
while (CURRENT_PAGE < PAGES) {
const URL = `https://api.music.apple.com/v1/me/recent/played/tracks?limit=${PAGE_SIZE}&offset=${
PAGE_SIZE * CURRENT_PAGE
}`
const tracks = await fetch(URL, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${APPLE_BEARER}`,
'music-user-token': `${APPLE_TOKEN}`,
},
})
.then((data) => data.json())
.catch()
res = [...res, ...tracks.data]
CURRENT_PAGE++
}
res.forEach((track) => {
// aggregate artists
if (!response.artists[track.attributes.artistName]) {
response.artists[track.attributes.artistName] = {
name: track.attributes.artistName,
plays: 1,
}
} else {
response.artists[track.attributes.artistName].plays++
}
// aggregate albums
if (!response.albums[track.attributes.albumName]) {
response.albums[track.attributes.albumName] = {
name: track.attributes.albumName,
artist: track.attributes.artistName,
art: track.attributes.artwork.url.replace('{w}', '300').replace('{h}', '300'),
plays: 1,
}
} else {
response.albums[track.attributes.albumName].plays++
}
// aggregate tracks
if (!response.tracks[track.attributes.name]) {
response.tracks[track.attributes.name] = {
name: track.attributes.name,
plays: 1,
}
} else {
response.tracks[track.attributes.name].plays++
}
})
response.artists = sortTrim(response.artists)
response.albums = sortTrim(response.albums)
response.tracks = sortTrim(response.tracks, 5)
await asset.save(response, 'json')
return response
}
```
We start by defining `sortTrim` as a helper function that takes an array of objects, sorts them in descending order by play count and trims the resulting array to the default length. We'll use this later.
Next, we define a range of constants — the tokens we obtained earlier — and fixed page size values. Apple's `/recent/played/` endpoint returns no more than `30` tracks at a time, so we'll be calling it iteratively.
Within the `while` statement we'll construct our endpoint url complete with parameters (changing the `limit` and `offset`) with each iteration and aggregate the data in the `res` array. Once we have an array of track data populated (in this case, `8 * 30 = 240` tracks), we'll aggregate some vaguely meaningful data in our `response` object.
For each track we'll populate `artist`, `album` and `track` data — for artists and tracks we simply want a name and a play count, for albums we also want to populate an `art` property with an url. Apple's api returns this url as a string with `{h}` and `{w}` tokens to be replaced (300x300 is sufficient for my purposes). Once we've aggregated this data, we cache it using the `AssetCache` from `@eleventy/fetch`.
As an example, the `artists` property in the output should look like this:
```json
artists: [
{ name: 'Deiquisitor', plays: 27 },
{ name: 'Prince Daddy & the Hyena', plays: 26 },
{ name: 'Oranssi Pazuzu', plays: 19 },
{ name: 'Joyce Manor', plays: 18 },
{ name: 'Nucleus', plays: 17 },
{ name: 'Drug Church', plays: 17 },
{ name: 'Sunken', plays: 12 },
{ name: 'Home Is Where', plays: 10 }
],
```
The templating for my site is all written in [liquid.js](https://liquidjs.com) and looks like the following:
{% raw %}
```liquid
{% if recentTracks.size > 0 %}
<h2 class="m-0 text-xl flex flex-row items-center font-black leading-tight tracking-normal dark:text-gray-200 md:text-2xl mt-8 mb-4">
{% heroicon "outline" "microphone" "Artists" "height=28" %}
<div class="ml-1">Artists</div>
</h2>
<div class="grid grid-cols-2 gap-2 md:grid-cols-4 not-prose">
{% for artist in recentTracks.artists %}
<a href="https://rateyourmusic.com/search?searchterm={{ artist.name | escape }}" title="{{artist.name | escape}}">
<div class="relative block">
<div class="absolute left-0 top-0 h-full w-full rounded-lg border border-purple-600 hover:border-purple-500 bg-cover-gradient dark:border-purple-400 dark:hover:border-purple-500"></div>
<div class="absolute left-1 bottom-2 drop-shadow-md">
<div class="px-1 text-xs font-bold text-white">{{ artist.name }}</div>
<div class="px-1 text-xs text-white">
{{ artist.plays }} plays
</div>
</div>
{%- capture artistImg %}{{ artist.name | artist }}{% endcapture -%}
{%- capture artistName %}{{ artist.name | escape }}{% endcapture -%}
{% image artistImg, artistName, 'rounded-lg', '225px', 'eager' %}
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% if recentTracks.size > 0 %}
<h2 class="m-0 text-xl flex flex-row items-center font-black leading-tight tracking-normal dark:text-gray-200 md:text-2xl mt-8 mb-4">
{% heroicon "outline" "musical-note" "Albums" "height=28" %}
<div class="ml-1">Albums</div>
</h2>
<div class="grid grid-cols-2 gap-2 md:grid-cols-4 not-prose">
{% for album in recentTracks.albums %}
<a href="https://rateyourmusic.com/search?searchtype=l&searchterm={{album.name | escape}}" title="{{album.name | escape}}">
<div class="relative block">
<div class="absolute left-0 top-0 h-full w-full rounded-lg border border-purple-600 hover:border-purple-500 bg-cover-gradient dark:border-purple-400 dark:hover:border-purple-500"></div>
<div class="absolute left-1 bottom-2 drop-shadow-md">
<div class="px-1 text-xs font-bold text-white">{{ album.name }}</div>
<div class="px-1 text-xs text-white">
{{ album.artist }}
</div>
</div>
{%- capture albumName %}{{ album.name | escape }}{% endcapture -%}
{% image album.art, albumName, 'rounded-lg', '225px' %}
</div>
</a>
{% endfor %}
</div>
{% endif %}
```
{% endraw %}
We have an object containing arrays of objects — we iterate through each object for the appropriate section (tracks aren't displayed at the moment) and build the resulting display[^3]. This isn't perfect by any means, but, it does provide a nice little visualization of what I'm listening to and `240` tracks feels adequate as a rolling window into that activity.
{% image 'https://cdn.coryd.dev/blog/albums-artists.jpg', 'Albums and artists', 'w-full', '600px' %}
[^1]: There are some good options to do this, but there aren't a _ton_ and the age of some of the apps is concerning. [Marvis](https://appaddy.wixsite.com/marvis) is far and away your best choice here.
[^2]: Making sure that you update the values you obtained, including the path to your downloaded `.p8` file.
[^3]: I'm linking each artist or album out to [Rate Your Music](https://rateyourmusic.com) as it's not platform specific and due to the fact that Apple's api doesn't return valid links for library tracks that I've imported into their service.

View file

@ -26,8 +26,8 @@ meta:
Referral links for services I use. I save some money, and you do as well if you choose to use them.
- [Fastmail](https://ref.fm/u28939392)
- [NextDNS](https://nextdns.io/?from=m56mt3z6)
- [DNSimple](https://dnsimple.com/r/3a7cbb9e15df8f)
- [Bunny.net](https://bunny.net?ref=revw3mehej)
- [DigitalOcean](https://m.do.co/c/3635bf99aee2)
- <a href="https://ref.fm/u28939392" onclick="umami.track('fastmail-referral', { name: 'Fastmail referral', url: '{{ page.url }}' });">Fastmail</a>
- <a href="https://nextdns.io/?from=m56mt3z6" onclick="umami.track('nextdns-referral', { name: 'NextDNS referral', url: '{{ page.url }}' });">NextDNS</a>
- <a href="https://dnsimple.com/r/3a7cbb9e15df8f" onclick="umami.track('dnsimple-referral', { name: 'DNSimple referral', url: '{{ page.url }}' });">DNSimple</a>
- <a href="https://bunny.net?ref=revw3mehej" onclick="umami.track('bunny-referral', { name: 'Bunny.net referral', url: '{{ page.url }}' });">Bunny.net</a>
- <a href="https://m.do.co/c/3635bf99aee2" onclick="umami.track('digitalocean-referral', { name: 'DigitalOcean referral', url: '{{ page.url }}' });">DigitalOcean</a>

View file

@ -70,10 +70,11 @@ Software and services that I use for work and my own enjoyment.
<h3 className="text-xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-2xl sm:leading-10 md:text-4xl md:leading-14">Services</h3>
- [Fastmail](https://ref.fm/u28939392)
- [NextDNS](https://nextdns.io/?from=m56mt3z6)
- [DNSimple](https://dnsimple.com/r/3a7cbb9e15df8f)
- [Bunny.net](https://bunny.net?ref=revw3mehej)
- <a href="https://ref.fm/u28939392" onclick="umami.track('fastmail-referral', { name: 'Fastmail referral', url: '{{ page.url }}' });">Fastmail</a>
- <a href="https://nextdns.io/?from=m56mt3z6" onclick="umami.track('nextdns-referral', { name: 'NextDNS referral', url: '{{ page.url }}' });">NextDNS</a>
- <a href="https://dnsimple.com/r/3a7cbb9e15df8f" onclick="umami.track('dnsimple-referral', { name: 'DNSimple referral', url: '{{ page.url }}' });">DNSimple</a>
- <a href="https://bunny.net?ref=revw3mehej" onclick="umami.track('bunny-referral', { name: 'Bunny.net referral', url: '{{ page.url }}' });">Bunny.net</a>
- [Umami analytics](https://umami.is)
- [Mullvad](https://mullvad.net)
- [forwardemail.net](https://forwardemail.net)
- [1Password](https://1password.com)

View file

@ -111,6 +111,7 @@ pre {
@apply mr-2;
@apply mb-2;
@apply text-sm;
@apply inline-block;
}
.dark .tag--button {

View file

@ -3863,11 +3863,6 @@ neo-async@^2.6.0:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
netlify-plugin-cache@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/netlify-plugin-cache/-/netlify-plugin-cache-1.0.3.tgz#f60514e259dff2b3286b6d60b570bb1c81206794"
integrity sha512-CTOwNWrTOP59T6y6unxQNnp1WX702v2R/faR5peSH94ebrYfyY4zT5IsRcIiHKq57jXeyCrhy0GLuTN8ktzuQg==
no-case@^2.2.0:
version "2.3.2"
resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
@ -4800,10 +4795,10 @@ safe-regex-test@^1.0.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sanitize-html@^2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.10.0.tgz#74d28848dfcf72c39693139131895c78900ab452"
integrity sha512-JqdovUd81dG4k87vZt6uA6YhDfWkUGruUu/aPmXLxXi45gZExnt9Bnw/qeQU8oGf82vPyaE0vO4aH0PbobB9JQ==
sanitize-html@^2.11.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.11.0.tgz#9a6434ee8fcaeddc740d8ae7cd5dd71d3981f8f6"
integrity sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==
dependencies:
deepmerge "^4.2.2"
escape-string-regexp "^4.0.0"