feat: apple music -> last.fm

This commit is contained in:
Cory Dransfeldt 2023-07-26 16:37:16 -07:00
parent fc59d929b4
commit 657e21e9cd
No known key found for this signature in database
22 changed files with 88 additions and 1347 deletions

7
.env
View file

@ -1,11 +1,6 @@
ACCESS_KEY_WASABI=
SECRET_KEY_WASABI=
BUCKET_WASABI=
API_KEY_LASTFM=
API_KEY_PLAUSIBLE=
API_KEY_TRAKT=
API_KEY_WEBMENTIONS_CORYD_DEV=
API_TOKEN_READWISE=
API_BEARER_APPLE_MUSIC=
API_TOKEN_APPLE_MUSIC=
APPLE_RENEW_TOKEN_URL=
SECRET_FEED_ALBUM_RELEASES=

View file

@ -1,7 +1,14 @@
const ALBUM_DENYLIST = ['no-love-deep-web', 'unremittance']
module.exports = {
artist: (media) =>
`https://cdn.coryd.dev/artists/${media.replace(/\s+/g, '-').toLowerCase()}.jpg` ||
'https://cdn.coryd.dev/artists/missing-artist.jpg',
album: (media) => {
return !ALBUM_DENYLIST.includes(media.name.replace(/\s+/g, '-').toLowerCase())
? media.replace('https://lastfm.freetls.fastly.net', 'https://albums.coryd.dev')
: `https://cdn.coryd.dev/albums/${media.name.replace(/\s+/g, '-').toLowerCase()}.jpg`
},
tv: (episode) =>
`https://cdn.coryd.dev/tv/${episode.replace(':', '').replace(/\s+/g, '-').toLowerCase()}.jpg` ||
'https://cdn.coryd.dev/tv/missing-tv.jpg',

View file

@ -21,7 +21,6 @@
"@11ty/eleventy-fetch": "^4.0.0",
"@11ty/eleventy-img": "^3.1.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
"@aws-sdk/client-s3": "^3.377.0",
"@catppuccin/tailwindcss": "^0.1.6",
"@commitlint/cli": "^17.6.6",
"@commitlint/config-conventional": "^17.6.6",
@ -48,7 +47,6 @@
"ics-to-json-extended": "^1.1.4",
"lint-staged": "^13.2.3",
"liquidjs": "^10.8.3",
"lodash": "^4.17.21",
"luxon": "^3.3.0",
"markdown-it": "^13.0.1",
"markdown-it-anchor": "^8.4.1",

29
src/_data/albums.js Normal file
View file

@ -0,0 +1,29 @@
const EleventyFetch = require('@11ty/eleventy-fetch')
const ALBUM_DENYLIST = ['no-love-deep-web', 'unremittance']
module.exports = async function () {
const MUSIC_KEY = process.env.API_KEY_LASTFM
const url = `https://ws.audioscrobbler.com/2.0/?method=user.gettopalbums&user=cdrn_&api_key=${MUSIC_KEY}&limit=8&format=json&period=7day`
const res = EleventyFetch(url, {
duration: '1h',
type: 'json',
}).catch()
const data = await res
return data['topalbums'].album.map((album) => {
return {
name: album['name'],
artist: album['artist']['name'],
plays: album['playcount'],
rank: album['@attr']['rank'],
image: !ALBUM_DENYLIST.includes(album['name'].replace(/\s+/g, '-').toLowerCase())
? album['image'][album['image'].length - 1]['#text'].replace(
'https://lastfm.freetls.fastly.net',
'https://albums.coryd.dev'
)
: `https://cdn.coryd.dev/albums/${album['name'].name
.replace(/\s+/g, '-')
.toLowerCase()}.jpg`,
url: `https://musicbrainz.org/album/${album['mbid']}`,
}
})
}

22
src/_data/artists.js Normal file
View file

@ -0,0 +1,22 @@
const EleventyFetch = require('@11ty/eleventy-fetch')
module.exports = async function () {
const MUSIC_KEY = process.env.API_KEY_LASTFM
const url = `https://ws.audioscrobbler.com/2.0/?method=user.gettopartists&user=cdrn_&api_key=${MUSIC_KEY}&limit=8&format=json&period=7day`
const res = EleventyFetch(url, {
duration: '1h',
type: 'json',
}).catch()
const data = await res
return data['topartists'].artist.map((artist) => {
return {
name: artist['name'],
plays: artist['playcount'],
rank: artist['@attr']['rank'],
image:
`https://cdn.coryd.dev/artists/${artist['name'].replace(/\s+/g, '-').toLowerCase()}.jpg` ||
'https://cdn.coryd.dev/artists/missing-artist.jpg',
url: `https://musicbrainz.org/artist/${artist['mbid']}`,
}
})
}

View file

@ -1,28 +0,0 @@
{
"aliases": [
{
"artist": "Aesop Rock",
"aliases": ["Aesop Rock & Homeboy Sandman", "Aesop Rock & Blockhead"]
},
{
"artist": "Fen",
"aliases": ["Sleepwalker & Fen"]
},
{
"artist": "Osees",
"aliases": ["OCS", "The Ohsees", "Thee Oh Sees", "Thee Oh See's"]
},
{
"artist": "Ryan Adams",
"aliases": ["Ryan Adams & the Cardinals"]
},
{
"artist": "Thou",
"aliases": ["Great Falls / Thou", "Moloch / Thou", "Thou & The Body"]
},
{
"artist": "Tom Waits",
"aliases": ["Tom Waits & Crystal Gayle", "Crystal Gayle"]
}
]
}

View file

@ -1,12 +0,0 @@
{
"i.rXXXdmUa6Nme-1689970612847": {
"name": "Sacrificial Blood Oath In The Temple Of K'zadu",
"artist": "Gateway",
"album": "Galgendood",
"art": "https://store-033.blobstore.apple.com/sq-mq-us-033-000002/18/f1/a3/18f1a37a-8c9a-169a-5458-464aea20ce05/image?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20230721T202228Z&X-Amz-SignedHeaders=host&X-Amz-Expires=86400&X-Amz-Credential=MKIAU0HKO2RBEAT0UMZS%2F20230721%2Fstore-033%2Fs3%2Faws4_request&X-Amz-Signature=85790600221880597074559ed3674564f17ca3df6634d6fa15496baf7aca5d56",
"url": "https://rateyourmusic.com/search?searchtype=l&searchterm=Galgendood%20Gateway",
"id": "i.rXXXdmUa6Nme",
"playTime": 1689970612847,
"duration": 338808
}
}

View file

@ -1,20 +0,0 @@
{
"words": [
"a",
"and",
"but",
"an",
"for",
"if",
"in",
"is",
"it",
"nor",
"of",
"or",
"so",
"the",
"yet"
],
"artists": ["NoMeansNo"]
}

View file

@ -1,179 +0,0 @@
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3')
const _ = require('lodash')
const mockedMusic = require('./json/mocks/music.json')
const { getReadableData } = require('../utils/aws')
const { aliasArtist, sanitizeMedia } = require('../utils/media')
const { titleCase } = require('../utils/grammar')
const diffTracks = (cache, tracks) => {
const trackCompareSet = Object.values(tracks)
const cacheCompareSet = _.orderBy(Object.values(cache), ['time'], ['desc'])
const diffedTracks = {}
const comparedTracks = _.differenceWith(trackCompareSet, cacheCompareSet, (a, b) =>
_.isEqual(a.id, b.id)
)
for (let i = 0; i < comparedTracks.length; i++)
diffedTracks[`${comparedTracks[i]?.id}-${comparedTracks[i].playTime}`] = comparedTracks[i]
return diffedTracks
}
const formatTracks = (tracks) => {
let formattedTracks = {}
let time = new Date().getTime()
Object.values(tracks).forEach((track) => {
const artistFormatted = titleCase(aliasArtist(track.attributes['artistName']))
const albumFormatted = titleCase(sanitizeMedia(track.attributes['albumName']))
const trackFormatted = sanitizeMedia(track.attributes['name'])
formattedTracks[`${track.id}-${time}`] = {
name: trackFormatted,
artist: artistFormatted,
album: albumFormatted,
genre: track['relationships']?.['library'].data[0]?.attributes['genreNames'][0] || '',
art: track.attributes.artwork.url.replace('{w}', '300').replace('{h}', '300'),
url:
track['relationships'] && track['relationships'].albums.data.length > 0
? `https://song.link/${track['relationships'].albums.data.pop().attributes.url}`
: `https://rateyourmusic.com/search?searchtype=l&searchterm=${encodeURI(
albumFormatted
)}%20${encodeURI(artistFormatted)}`,
id: track.id,
playTime: time - parseInt(track.attributes['durationInMillis']),
duration: parseInt(track.attributes['durationInMillis']),
}
})
return formattedTracks
}
const deriveCharts = (tracks) => {
const charts = {
artists: {},
albums: {},
}
Object.values(tracks).forEach((track) => {
if (!charts.artists[track.artist]) {
charts.artists[track.artist] = {
artist: track.artist,
genre: track.genre,
url: `https://rateyourmusic.com/search?searchterm=${encodeURI(track.artist)}`,
plays: 1,
}
} else {
charts.artists[track.artist].plays++
}
if (!charts.albums[track.album]) {
charts.albums[track.album] = {
name: track.album,
artist: track.artist,
art: track.art,
url: track.url,
plays: 1,
}
} else {
charts.albums[track.album].plays++
}
})
return charts
}
module.exports = async function () {
const client = new S3Client({
credentials: {
accessKeyId: process.env.ACCESS_KEY_WASABI,
secretAccessKey: process.env.SECRET_KEY_WASABI,
},
endpoint: {
url: 'https://s3.us-west-1.wasabisys.com',
},
region: 'us-west-1',
})
const WASABI_BUCKET = process.env.BUCKET_WASABI
const APPLE_BEARER = process.env.API_BEARER_APPLE_MUSIC
const APPLE_MUSIC_TOKEN = process.env.API_TOKEN_APPLE_MUSIC
const APPLE_TOKEN_RESPONSE = await fetch(process.env.APPLE_RENEW_TOKEN_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${APPLE_BEARER}`,
'X-Apple-Music-User-Token': APPLE_MUSIC_TOKEN,
},
})
.then((data) => data.json())
.catch()
const DATE = new Date()
DATE.setDate(DATE.getDate() + ((7 - DATE.getDay()) % 7))
const DATE_STAMP = `${DATE.getFullYear()}-${DATE.getDate()}-${DATE.getMonth()}`
const APPLE_TOKEN = APPLE_TOKEN_RESPONSE['music-token']
const PAGE_SIZE = 30
const PAGES = 10
let charts
let CURRENT_PAGE = 0
let hasNextPage = true
let res = []
let cachedTracks = mockedMusic
while (CURRENT_PAGE < PAGES && hasNextPage) {
const URL = `https://api.music.apple.com/v1/me/recent/played/tracks?limit=${PAGE_SIZE}&offset=${
PAGE_SIZE * CURRENT_PAGE
}&include[songs]=albums,library&extend=artistUrl`
const tracks = await fetch(URL, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${APPLE_BEARER}`,
'music-user-token': `${APPLE_TOKEN}`,
},
})
.then((data) => data.json())
.catch()
if (!tracks.next) hasNextPage = false
if (tracks.data.length) res = [...res, ...tracks.data]
CURRENT_PAGE++
}
if (process.env.ELEVENTY_PRODUCTION === 'true') {
try {
const cachedTracksOutput = await client.send(
new GetObjectCommand({
Bucket: WASABI_BUCKET,
Key: `${DATE_STAMP}-music-history.json`,
})
)
const cachedTracksData = getReadableData(cachedTracksOutput.Body)
cachedTracks = await cachedTracksData.then((tracks) => JSON.parse(tracks)).catch()
} catch (e) {
console.log('No cached tracks')
cachedTracks = {}
}
}
const diffedTracks = diffTracks(cachedTracks, formatTracks(res))
const updatedCache = {
...cachedTracks,
...diffedTracks,
}
charts = deriveCharts(updatedCache)
charts.artists = _.orderBy(Object.values(charts.artists), ['plays'], ['desc']).splice(0, 8)
charts.albums = _.orderBy(Object.values(charts.albums), ['plays'], ['desc']).splice(0, 8)
if (!_.isEmpty(diffedTracks) && process.env.ELEVENTY_PRODUCTION === 'true') {
await client.send(
new PutObjectCommand({
Bucket: WASABI_BUCKET,
Key: `${DATE_STAMP}-music-history.json`,
Body: JSON.stringify(updatedCache),
})
)
}
return charts
}

View file

@ -1,12 +1,13 @@
module.exports = async function () {
return {
"name": "Cory Dransfeldt",
"email": "hi@coryd.dev",
"url": "https://coryd.dev",
"logo": "https://coryd.dev/assets/img/logo.webp",
"title": "Cory Dransfeldt",
"description": "I'm a software developer in Camarillo, California. I enjoy hanging out with my beautiful family and 4 rescue dogs, technology, automation, music, writing, reading and tv and movies.",
"letterboxd-host": "https://a.ltrbxd.com",
"cdn-movies": "https://movies.coryd.dev"
name: 'Cory Dransfeldt',
email: 'hi@coryd.dev',
url: 'https://coryd.dev',
logo: 'https://coryd.dev/assets/img/logo.webp',
title: 'Cory Dransfeldt',
description:
"I'm a software developer in Camarillo, California. I enjoy hanging out with my beautiful family and 4 rescue dogs, technology, automation, music, writing, reading and tv and movies.",
'letterboxd-host': 'https://a.ltrbxd.com',
'cdn-movies': 'https://movies.coryd.dev',
}
}

View file

@ -1,7 +0,0 @@
<a
class="link--icon"
href="https://music.apple.com/profile/cdransf"
rel="me"
title="Apple Music">
{% tablericon "device-airpods" "Apple Music" "height=24" %}
</a>

View file

@ -0,0 +1,7 @@
<a
class="link--icon"
href="https://www.last.fm/user/cdrn_"
rel="me"
title="Last.fm">
{% tablericon "brand-lastfm" "Last.fm" "height=24" %}
</a>

View file

@ -3,8 +3,8 @@ layout: main
---
{% render "partials/header.liquid", site: site, page: page %}
{{ content }}
{% render "partials/now/artists.liquid", music:music %}
{% render "partials/now/albums.liquid", music:music %}
{% render "partials/now/artists.liquid", music:artists %}
{% render "partials/now/albums.liquid", music:albums %}
{% render "partials/now/albumReleases.liquid", albumReleases:albumReleases %}
{% render "partials/now/books.liquid", books:books %}
{% render "partials/now/links.liquid", links:links %}

View file

@ -3,7 +3,7 @@
{% render "icons/email.liquid" %}
{% render "icons/github.liquid" %}
{% render "icons/mastodon.liquid" %}
{% render "icons/apple-music.liquid" %}
{% render "icons/lastfm.liquid" %}
{% render "icons/letterboxd.liquid" %}
{% render "icons/trakt.liquid" %}
{% render "icons/coffee.liquid" %}

View file

@ -4,7 +4,7 @@
<div class="ml-1">Albums</div>
</h2>
<div class="grid grid-cols-2 gap-2 md:grid-cols-4 not-prose">
{% for album in music.albums %}
{% for album in music %}
<a href="{{ album.url }}" title="{{album.name | escape}} by {{ album.artist | escape }}">
<div class="relative block h-full">
<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>
@ -15,7 +15,7 @@
</div>
</div>
{%- capture albumName %}{{ album.name | escape }}{% endcapture -%}
{% image album.art, albumName, 'rounded-lg w-full h-full', '225px' %}
{% image album.image, albumName, 'rounded-lg w-full h-full', '225px' %}
</div>
</a>
{% endfor %}

View file

@ -4,18 +4,18 @@
<div class="ml-1">Artists</div>
</h2>
<div class="grid grid-cols-2 gap-2 md:grid-cols-4 not-prose">
{% for artist in music.artists %}
<a href="{{ artist.url }}" title="{{artist.artist | escape}}">
{% for artist in music %}
<a href="{{ artist.url }}" 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 line-clamp-2">{{ artist.artist }}</div>
<div class="px-1 text-xs font-bold text-white line-clamp-2">{{ artist.name }}</div>
<div class="px-1 text-xs text-white">
{{ artist.plays }} plays
</div>
</div>
{%- capture artistImg %}{{ artist.artist | artist }}{% endcapture -%}
{%- capture artistName %}{{ artist.artist | escape }}{% endcapture -%}
{%- capture artistImg %}{{ artist.image }}{% endcapture -%}
{%- capture artistName %}{{ artist.name | escape }}{% endcapture -%}
{% image artistImg, artistName, 'rounded-lg w-full', '225px', 'eager' %}
</div>
</a>

View file

@ -22,7 +22,7 @@ meta:
</div>
<h1 class="text-xxl font-black -leading-tight tracking-normal dark:text-gray-200 md:text-3xl text-center">Hi, I'm Cory</h1>
I'm a software developer in Camarillo, California. I enjoy hanging out with my beautiful family and 4 rescue dogs, technology, automation, <a href="https://music.apple.com/profile/cdransf" rel="noopener noreferrer">music</a>, writing, <a href="/now" rel="noopener noreferrer">reading</a> and <a href="https://trakt.tv/users/cdransf" rel="noopener noreferrer">tv</a> and <a href="https://letterboxd.com/cdme" rel="noopener noreferrer">movies</a>.
I'm a software developer in Camarillo, California. I enjoy hanging out with my beautiful family and 4 rescue dogs, technology, automation, <a href="https://www.last.fm/user/cdrn_" rel="noopener noreferrer">music</a>, writing, <a href="/now" rel="noopener noreferrer">reading</a> and <a href="https://trakt.tv/users/cdransf" rel="noopener noreferrer">tv</a> and <a href="https://letterboxd.com/cdme" rel="noopener noreferrer">movies</a>.
I build, maintain and design web applications. I've been coding professionally since 2010 with a focus on frontend technologies.

View file

@ -1,3 +0,0 @@
module.exports = {
getKeyByValue: (object, value) => Object.keys(object).find((key) => object[key].includes(value)),
}

View file

@ -1,10 +0,0 @@
module.exports = {
getReadableData: (readable) => {
return new Promise((resolve, reject) => {
const chunks = []
readable.once('error', (err) => reject(err))
readable.on('data', (chunk) => chunks.push(chunk))
readable.once('end', () => resolve(chunks.join('')))
})
},
}

View file

@ -1,24 +0,0 @@
const titleCaseExceptions = require('./../_data/json/title-case-exceptions.json')
module.exports = {
/**
* Accepts a string that is then transformed to title case and returned.
*
* @name titleCase
* @param {string} string
* @returns {string}
*/
titleCase: (string) => {
if (!string) return ''
if (titleCaseExceptions.artists.includes(string)) return string
return string
.toLowerCase()
.split(' ')
.map((word, i) => {
return titleCaseExceptions.words.includes(word) && i !== 0
? word
: word.charAt(0).toUpperCase().concat(word.substring(1))
})
.join(' ')
},
}

View file

@ -1,33 +0,0 @@
const artistAliases = require('../_data/json/artist-aliases.json')
module.exports = {
/**
* Accepts a string representing an artist name, checks to see if said artist name
* exists in an artist alias group of shape string[]. If so, replaces the provided
* artist name with the canonical artist name.
*
* @name aliasArtist
* @param {string} artist
* @returns {string}
*/
aliasArtist: (artist) => {
const aliased = artistAliases.aliases.find((alias) => alias.aliases.includes(artist))
if (aliased) artist = aliased.artist
return artist
},
/**
* Accepts a media name represented as a string (album or song name) and replaces
* matches in the `denyList` with an empty string before returning the result.
*
* @name sanitizeMedia
* @param {string} media
* @returns {string}
*/
sanitizeMedia: (media) => {
const denyList =
/-\s*(?:single|ep)\s*|(\[|\()(Deluxe Edition|Special Edition|Remastered|Full Dynamic Range Edition|Anniversary Edition)(\]|\))/gi
return media.replace(denyList, '').trim()
},
sortByPlays: (array) => Object.values(array).sort((a, b) => b.plays - a.plays),
}

1006
yarn.lock

File diff suppressed because it is too large Load diff