feat: data storage

This commit is contained in:
Cory Dransfeldt 2024-05-07 17:09:47 -07:00
parent bcc6ea0987
commit d8137dca96
No known key found for this signature in database
29 changed files with 428 additions and 14449 deletions

4
.env
View file

@ -3,4 +3,6 @@ SITE_KEY_CLICKY=
API_KEY_TRAKT=
API_KEY_MOVIEDB=
SECRET_FEED_ALBUM_RELEASES=
ACCOUNT_ID_PLEX=
ACCOUNT_ID_PLEX=
SUPABASE_URL=
SUPABASE_KEY=

View file

@ -1,4 +1,8 @@
import { getStore } from '@netlify/blobs'
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = process.env.SUPABASE_URL
const SUPABASE_KEY = process.env.SUPABASE_KEY
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
const emojiMap = (genre, artist) => {
const DEFAULT = "🎧"
@ -77,28 +81,43 @@ const emojiMap = (genre, artist) => {
}
export default async () => {
const scrobbles = getStore('scrobbles')
const { data, error } = await supabase
.from('listens')
.select(`
track_name,
artist_name,
artists (mbid, genre),
`)
.order('listened_at', { ascending: false })
.limit(1);
const headers = {
"Content-Type": "application/json",
"Cache-Control": "public, s-maxage=360, stale-while-revalidate=1080",
}
const scrobbleData = await scrobbles.get('now-playing', { type: 'json'})
};
if (!scrobbleData) return new Response(JSON.stringify({}, { headers }))
if (error) {
console.error('Error fetching data:', error);
return new Response(JSON.stringify({ error: "Failed to fetch the latest track" }), { headers });
}
if (data.length === 0) {
return new Response(JSON.stringify({ message: "No recent tracks found" }), { headers });
}
const scrobbleData = data[0]
return new Response(JSON.stringify({
content: `${emojiMap(
scrobbleData['genre'],
scrobbleData['artist']
)} ${scrobbleData['track']} by <a href="${scrobbleData['url']}">${
scrobbleData['artist']
}</a>`,
}),
{ headers }
)
}
content: `${emojiMap(
scrobbleData.artists.genre,
scrobbleData.artist_name
)} ${scrobbleData.track_name} by <a href="http://musicbrainz.org/artist/${scrobbleData.artists.mbid}">${
scrobbleData.artist_name
}</a>`,
}), { headers });
};
export const config = {
cache: "manual",
path: "/api/now-playing"
}
};

View file

@ -1,90 +1,78 @@
import { getStore } from '@netlify/blobs'
import { createClient } from '@supabase/supabase-js'
import { DateTime } from 'luxon'
const SUPABASE_URL = Netlify.env.get('SUPABASE_URL')
const SUPABASE_KEY = Netlify.env.get('SUPABASE_API_KEY')
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY)
const sanitizeMediaString = (string) => string.normalize('NFD').replace(/[\u0300-\u036f\u2010—\.\?\(\)\[\]\{\}]/g, '').replace(/\.{3}/g, '')
const weekKey = () => {
const currentDate = DateTime.now();
return `${currentDate.year}-${currentDate.weekNumber}`
}
const filterOldScrobbles = (scrobbles) => {
const windowEnd = DateTime.now().minus({ days: 7 });
return scrobbles.filter(scrobble => {
const timestamp = DateTime.fromISO(scrobble.timestamp);
return timestamp >= windowEnd;
});
}
export default async (request) => {
const ACCOUNT_ID_PLEX = Netlify.env.get('ACCOUNT_ID_PLEX');
const MUSIC_KEY = Netlify.env.get('API_KEY_LASTFM');
const params = new URL(request['url']).searchParams
const ACCOUNT_ID_PLEX = process.env.ACCOUNT_ID_PLEX
const params = new URL(request.url).searchParams
const id = params.get('id')
if (!id) return new Response(JSON.stringify({
status: 'Bad request',
}),
{ headers: { "Content-Type": "application/json" } }
)
if (id !== ACCOUNT_ID_PLEX) return new Response(JSON.stringify({
status: 'Forbidden',
}),
{ headers: { "Content-Type": "application/json" } }
)
if (!id) return new Response(JSON.stringify({ status: 'Bad request' }), { headers: { "Content-Type": "application/json" } })
if (id !== ACCOUNT_ID_PLEX) return new Response(JSON.stringify({ status: 'Forbidden' }), { headers: { "Content-Type": "application/json" } })
const data = await request.formData()
const payload = JSON.parse(data.get('payload'))
const artists = getStore('artists')
const scrobbles = getStore('scrobbles')
if (payload?.event === 'media.scrobble') {
const artist = payload['Metadata']['grandparentTitle']
const album = payload['Metadata']['parentTitle']
const track = payload['Metadata']['title']
const trackNumber = payload['Metadata']['index']
const timestamp = DateTime.now()
const artistsMap = await artists.get('artists-map', { type: 'json' })
const artistSanitizedKey = `${sanitizeMediaString(artist).replace(/\s+/g, '-').toLowerCase()}`
const trackScrobbleData = {
track,
album,
artist,
trackNumber,
timestamp,
genre: artistsMap[artistSanitizedKey]?.['genre'] || ''
const artist = payload.Metadata.grandparentTitle
const album = payload.Metadata.parentTitle
const track = payload.Metadata.title
const listenedAt = DateTime.now().toISO()
const artistKey = sanitizeMediaString(artist).replace(/\s+/g, '-').toLowerCase()
const albumKey = `${artistKey}-${sanitizeMediaString(album).replace(/\s+/g, '-').toLowerCase()}`
const { data: albumData, error: albumError } = await supabase
.from('albums')
.select('*')
.eq('key', albumKey)
.single()
if (albumError && albumError.code === 'PGRST116') {
const albumImageUrl = `https://coryd.dev/media/albums/${albumKey}.jpg`
const albumMBID = null
const { error: insertAlbumError } = await supabase.from('albums').insert([
{
mbid: albumMBID,
image: albumImageUrl,
key: albumKey,
name: album,
tentative: true
}
])
if (insertAlbumError) {
console.error('Error inserting album into Supabase:', insertAlbumError.message)
return new Response(JSON.stringify({ status: 'error', message: insertAlbumError.message }), { headers: { "Content-Type": "application/json" } })
}
} else if (albumError) {
console.error('Error querying album from Supabase:', albumError.message)
return new Response(JSON.stringify({ status: 'error', message: albumError.message }), { headers: { "Content-Type": "application/json" } })
}
const scrobbleData = await scrobbles.get(`${weekKey()}`, { type: 'json'})
const windowData = await scrobbles.get('window', { type: 'json'})
const artistUrl = (artistsMap[artistSanitizedKey]?.['mbid'] && artistsMap[artistSanitizedKey]?.['mbid'] !== '') ? `http://musicbrainz.org/artist/${artistsMap[artistSanitizedKey]?.['mbid']}` : `https://musicbrainz.org/search?query=${artist.replace(
/\s+/g,
'+'
)}&type=artist`
const { error: listenError } = await supabase.from('listens').insert([
{
artist_name: artist,
album_name: album,
track_name: track,
listened_at: listenedAt,
album_key: albumKey
}
])
await scrobbles.setJSON('now-playing', {...trackScrobbleData, ...{ url: artistUrl }})
let scrobbleUpdate = scrobbleData
let windowUpdate = windowData;
if (scrobbleUpdate?.['data']) scrobbleUpdate['data'].push(trackScrobbleData)
if (!scrobbleUpdate?.['data']) scrobbleUpdate = { data: [trackScrobbleData] }
if (windowData?.['data']) windowUpdate['data'].push(trackScrobbleData)
if (!windowData?.['data']) windowUpdate = { data: [trackScrobbleData] }
windowUpdate = { data: filterOldScrobbles(windowUpdate.data) }
await scrobbles.setJSON(`${weekKey()}`, scrobbleUpdate)
await scrobbles.setJSON('window', windowUpdate)
if (listenError) {
console.error('Error inserting data into Supabase:', listenError.message)
return new Response(JSON.stringify({ status: 'error', message: listenError.message }), { headers: { "Content-Type": "application/json" } })
}
}
return new Response(JSON.stringify({
status: 'success',
}),
{ headers: { "Content-Type": "application/json" } }
)
return new Response(JSON.stringify({ status: 'success' }), { headers: { "Content-Type": "application/json" } })
}
export const config = {
path: '/api/scrobble',
}
}

View file

@ -182,12 +182,12 @@ export default {
url: item['url'],
type: item.type
}
if (item.type === 'album') {
if (item.type === 'albums') {
normalized['title'] = item['title']
normalized['alt'] = `${item['title']} by ${item['artist']}`
normalized['subtext'] = `${item['artist']}`
}
if (item.type === 'artist') {
if (item.type === 'artists') {
normalized['title'] = item['title']
normalized['alt'] = `${item['plays']} plays of ${item['title']}`
normalized['subtext'] = `${item['plays']} plays`

142
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "coryd.dev",
"version": "13.7.2",
"version": "14.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coryd.dev",
"version": "13.7.2",
"version": "14.0.0",
"license": "MIT",
"dependencies": {
"@cdransf/api-text": "^1.2.2",
@ -27,6 +27,7 @@
"@11tyrocks/eleventy-plugin-lightningcss": "^1.4.0",
"@cdransf/eleventy-plugin-tabler-icons": "^1.3.0",
"@netlify/blobs": "^7.2.0",
"@supabase/supabase-js": "^2.43.1",
"dotenv-flow": "^4.1.0",
"gray-matter": "^4.0.3",
"html-minifier-terser": "^7.2.0",
@ -956,6 +957,80 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.64.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.64.2.tgz",
"integrity": "sha512-s+lkHEdGiczDrzXJ1YWt2y3bxRi+qIUnXcgkpLSrId7yjBeaXBFygNjTaoZLG02KNcYwbuZ9qkEIqmj2hF7svw==",
"dev": true,
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.3.1.tgz",
"integrity": "sha512-QyzNle/rVzlOi4BbVqxLSH828VdGY1RElqGFAj+XeVypj6+PVtMlD21G8SDnsPQDtlqqTtoGRgdMlQZih5hTuw==",
"dev": true,
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"dev": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.15.2.tgz",
"integrity": "sha512-9/7pUmXExvGuEK1yZhVYXPZnLEkDTwxgMQHXLrN5BwPZZm4iUCL1YEyep/Z2lIZah8d8M433mVAUEGsihUj5KQ==",
"dev": true,
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.9.5",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.9.5.tgz",
"integrity": "sha512-TEHlGwNGGmKPdeMtca1lFTYCedrhTAv3nZVoSjrKQ+wkMmaERuCe57zkC5KSWFzLYkb5FVHW8Hrr+PX1DDwplQ==",
"dev": true,
"dependencies": {
"@supabase/node-fetch": "^2.6.14",
"@types/phoenix": "^1.5.4",
"@types/ws": "^8.5.10",
"ws": "^8.14.2"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.5.5.tgz",
"integrity": "sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w==",
"dev": true,
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.43.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.43.1.tgz",
"integrity": "sha512-A+RV50mWNtyKo6M0u4G6AOqEifQD+MoOjZcpRkPMPpEAFgMsc2dt3kBlBlR/MgZizWQgUKhsvrwKk0efc8g6Ug==",
"dev": true,
"dependencies": {
"@supabase/auth-js": "2.64.2",
"@supabase/functions-js": "2.3.1",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.15.2",
"@supabase/realtime-js": "2.9.5",
"@supabase/storage-js": "2.5.5"
}
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@ -981,6 +1056,30 @@
"dev": true,
"peer": true
},
"node_modules/@types/node": {
"version": "20.12.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.10.tgz",
"integrity": "sha512-Eem5pH9pmWBHoGAT8Dr5fdc5rYA+4NAovdM4EktRPVAAiJhmWWfQrA0cFhAbOsQdSfIHjAud6YdkbL69+zSKjw==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.4.tgz",
"integrity": "sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==",
"dev": true
},
"node_modules/@types/ws": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
"integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@zachleat/webcare-webshare": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@zachleat/webcare-webshare/-/webcare-webshare-1.0.3.tgz",
@ -1765,9 +1864,9 @@
"dev": true
},
"node_modules/electron-to-chromium": {
"version": "1.4.756",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.756.tgz",
"integrity": "sha512-RJKZ9+vEBMeiPAvKNWyZjuYyUqMndcP1f335oHqn3BEQbs2NFtVrnK5+6Xg5wSM9TknNNpWghGDUCKGYF+xWXw==",
"version": "1.4.758",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.758.tgz",
"integrity": "sha512-/o9x6TCdrYZBMdGeTifAP3wlF/gVT+TtWJe3BSmtNh92Mw81U9hrYwW9OAGUh+sEOX/yz5e34sksqRruZbjYrw==",
"dev": true
},
"node_modules/encodeurl": {
@ -3018,18 +3117,6 @@
"tslib": "^2.0.3"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz",
@ -4314,13 +4401,10 @@
}
},
"node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"version": "7.6.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.1.tgz",
"integrity": "sha512-f/vbBsu+fOiYt+lmwZV0rVwJScl46HppnOA1ZvIuBWKOTlllpyJ3bfVax76/OrhCH38dyxoDIA8K7uB963IYgA==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@ -4669,6 +4753,12 @@
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"dev": true
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -4819,12 +4909,6 @@
"node": ">=4.0"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/youtube-video-element": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/youtube-video-element/-/youtube-video-element-1.0.1.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "coryd.dev",
"version": "13.7.2",
"version": "14.0.0",
"description": "The source for my personal site. Built using 11ty.",
"type": "module",
"scripts": {
@ -37,6 +37,7 @@
"@11tyrocks/eleventy-plugin-lightningcss": "^1.4.0",
"@cdransf/eleventy-plugin-tabler-icons": "^1.3.0",
"@netlify/blobs": "^7.2.0",
"@supabase/supabase-js": "^2.43.1",
"dotenv-flow": "^4.1.0",
"gray-matter": "^4.0.3",
"html-minifier-terser": "^7.2.0",

View file

@ -1,95 +0,0 @@
import { getStore, setEnvironmentContext } from '@netlify/blobs'
import { DateTime } from 'luxon'
import fs from 'fs'
const getKeys = (months = 1) => {
const currentDate = DateTime.now()
const weeks = Math.floor((currentDate.daysInMonth * months) / 7)
let count = 0
const keys = [`${currentDate.year}-${currentDate.weekNumber}`]
while (count < weeks) {
const weeks = 1 * (count + 1)
const date = DateTime.now().minus({ weeks })
keys.push(`${date.year}-${date.weekNumber}`)
count++;
}
return keys
}
const filterOldScrobbles = (scrobbles, months = 1) => {
const currentDate = DateTime.now()
const weeks = Math.floor((currentDate.daysInMonth * months) / 7)
const windowEnd = DateTime.now().minus({ weeks });
return scrobbles.filter(scrobble => {
const timestamp = DateTime.fromISO(scrobble.timestamp);
return timestamp >= windowEnd;
});
}
export const onPreBuild = async ({ constants }) => {
setEnvironmentContext({
siteID: constants.SITE_ID,
token: constants.NETLIFY_API_TOKEN,
})
const currentDate = DateTime.now()
const lastWeek = currentDate.minus({ weeks: 1 })
const monthKeys = getKeys()
const monthChartData = { data: [] }
const threeMonthKeys = getKeys(3)
const threeMonthChartData = { data: [] }
const yearKeys = getKeys(12)
const yearChartData = { data: [] }
const scrobbles = getStore('scrobbles')
const artists = getStore('artists')
const albums = getStore('albums')
const weeklyChartData = await scrobbles.get(`${lastWeek.year}-${lastWeek.weekNumber}`, { type: 'json'})
const windowData = await scrobbles.get('window', { type: 'json'})
const artistsMap = await artists.get('artists-map', { type: 'json' })
const albumsMap = await albums.get('albums-map', { type: 'json' })
const nowPlaying = await scrobbles.get('now-playing', { type: 'json'})
for (const key of monthKeys) {
let scrobbleData
try {
scrobbleData = await scrobbles.get(key, { type: 'json'})
} catch (err) {
console.log('Error fetching scrobble data using monthKeys')
break
}
if (scrobbleData) monthChartData?.['data']?.push(...scrobbleData?.['data'])
}
for (const key of threeMonthKeys) {
let scrobbleData
try {
scrobbleData = await scrobbles.get(key, { type: 'json'})
} catch (err) {
console.log('Error fetching scrobble data using threeMonthKeys')
break
}
if (scrobbleData) threeMonthChartData?.['data']?.push(...scrobbleData?.['data'])
}
for (const key of yearKeys) {
let scrobbleData
try {
scrobbleData = await scrobbles?.get(key, { type: 'json'})
} catch (err) {
console.log('Error fetching scrobble data using yearKeys')
break
}
if (scrobbleData) yearChartData?.['data']?.push(...scrobbleData?.['data'])
}
fs.writeFileSync('./src/_data/json/weekly-top-artists-chart.json', JSON.stringify({...weeklyChartData, timestamp: `${lastWeek.set({ hour: 8, minute: 0, second: 0, millisecond: 0 }).toMillis()}` }))
fs.writeFileSync('./src/_data/json/scrobbles-window.json', JSON.stringify(windowData))
fs.writeFileSync('./src/_data/json/artists-map.json', JSON.stringify(artistsMap))
fs.writeFileSync('./src/_data/json/albums-map.json', JSON.stringify(albumsMap))
fs.writeFileSync('./src/_data/json/now-playing.json', JSON.stringify(nowPlaying))
fs.writeFileSync('./src/_data/json/scrobbles-month-chart.json', JSON.stringify({ data: filterOldScrobbles(monthChartData.data) }))
fs.writeFileSync('./src/_data/json/scrobbles-three-month-chart.json', JSON.stringify({ data: filterOldScrobbles(threeMonthChartData.data, 3) }))
fs.writeFileSync('./src/_data/json/scrobbles-year-chart.json', JSON.stringify({ data: filterOldScrobbles(yearChartData.data, 12) }))
}

View file

@ -1 +0,0 @@
name: netlify-plugin-fetch-scrobbles

View file

@ -1,34 +0,0 @@
{
"name": "netlify-plugin-fetch-scrobbles",
"version": "0.0.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "netlify-plugin-fetch-scrobbles",
"version": "0.0.8",
"devDependencies": {
"@netlify/blobs": "^7.3.0",
"luxon": "^3.4.4"
}
},
"node_modules/@netlify/blobs": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-7.3.0.tgz",
"integrity": "sha512-wN/kNTZo4xjlUM/C0WILOkJbe8p4AFquSGkZEIoIcgnsx5ikp2GyqGiq1WMLee7QdbnqeIV2g2hn/PjT324E5w==",
"dev": true,
"engines": {
"node": "^14.16.0 || >=16.0.0"
}
},
"node_modules/luxon": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
"dev": true,
"engines": {
"node": ">=12"
}
}
}
}

View file

@ -1,11 +0,0 @@
{
"name": "netlify-plugin-fetch-scrobbles",
"version": "0.0.8",
"description": "netlify-plugin-fetch-scrobbles",
"main": "index.js",
"type": "module",
"devDependencies": {
"@netlify/blobs": "^7.3.0",
"luxon": "^3.4.4"
}
}

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

@ -0,0 +1,91 @@
import { createClient } from '@supabase/supabase-js'
import { DateTime } from 'luxon'
const SUPABASE_URL = process.env.SUPABASE_URL
const SUPABASE_KEY = process.env.SUPABASE_KEY
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY)
const fetchDataForPeriod = async (startPeriod, fields, table, allTime = false) => {
let query = supabase.from(table).select(fields).order('listened_at', { ascending: false })
if (!allTime) query = query.gte('listened_at', startPeriod.toUTC().toSeconds())
const { data, error } = await query
if (error) {
console.error('Error fetching data:', error)
return []
}
return data
}
const aggregateData = (data, groupByField, groupByType) => {
const aggregation = {}
data.forEach(item => {
const key = item[groupByField]
if (!aggregation[key]) {
if (groupByType === 'track') {
aggregation[key] = {
title: item[groupByField],
plays: 0,
mbid: item['albums']?.mbid || '',
url: item['albums']?.mbid ? `https://musicbrainz.org/release/${item['albums'].mbid}` : `https://musicbrainz.org/search?query=${encodeURIComponent(item['album_name'])}&type=release`,
image: item['albums']?.image || '',
type: groupByType
}
} else {
aggregation[key] = {
title: item[groupByField],
plays: 0,
mbid: item[groupByType]?.mbid || '',
url: item[groupByType]?.mbid ? `https://musicbrainz.org/${groupByType === 'albums' ? 'release' : 'artist'}/${item[groupByType].mbid}` : `https://musicbrainz.org/search?query=${encodeURIComponent(item[groupByField])}&type=${groupByType === 'albums' ? 'release' : 'artist'}`,
image: item[groupByType]?.image || '',
type: groupByType
}
}
}
aggregation[key].plays++
})
return Object.values(aggregation).sort((a, b) => b.plays - a.plays)
}
export default async function() {
const periods = {
week: DateTime.now().minus({ days: 7 }).startOf('day'), // Last week
month: DateTime.now().minus({ days: 30 }).startOf('day'), // Last 30 days
threeMonth: DateTime.now().minus({ months: 3 }).startOf('day'), // Last three months
year: DateTime.now().minus({ years: 1 }).startOf('day'), // Last 365 days
allTime: null // Null indicates no start period constraint
}
const results = {}
const selectFields = `
track_name,
artist_name,
album_name,
album_key,
artists (mbid, image),
albums (mbid, image)
`
for (const [period, startPeriod] of Object.entries(periods)) {
const isAllTime = period === 'allTime'
const periodData = await fetchDataForPeriod(startPeriod, selectFields, 'listens', isAllTime)
results[period] = {
artists: aggregateData(periodData, 'artist_name', 'artists'),
albums: aggregateData(periodData, 'album_name', 'albums'),
tracks: aggregateData(periodData, 'track_name', 'track')
}
}
const recentData = await fetchDataForPeriod(DateTime.now().minus({ days: 7 }), selectFields, 'listens')
results.recent = {
artists: aggregateData(recentData, 'artist_name', 'artists'),
albums: aggregateData(recentData, 'album_name', 'albums'),
tracks: aggregateData(recentData, 'track_name', 'track')
}
return results
}

View file

@ -1,91 +0,0 @@
const sanitizeMediaString = (string) => string.normalize('NFD').replace(/[\u0300-\u036f\u2010—\.\?\(\)\[\]\{\}]/g, '').replace(/\.{3}/g, '')
const artistSanitizedKey = (artist) => `${sanitizeMediaString(artist).replace(/\s+/g, '-').toLowerCase()}`
const albumSanitizedKey = (artist, album) => `${sanitizeMediaString(artist).replace(/\s+/g, '-').toLowerCase()}-${sanitizeMediaString(album.replace(/[:\/\\,'']+/g
, '').replace(/\s+/g, '-').toLowerCase())}`
export const buildChart = (tracks, artists, albums, nowPlaying = {}) => {
const artistsData = {}
const albumsData = {}
const tracksData = {}
const objectToArraySorted = (inputObject) => Object.values(inputObject).sort((a, b) => b.plays - a.plays)
tracks.forEach(track => {
if (!tracksData[track['track']]) {
const artistKey = artistSanitizedKey(track['artist'])
tracksData[track['track']] = {
artist: track['artist'],
title: track['track'],
plays: 1,
type: 'track',
url: (artists[artistKey]?.['mbid'] && artists[artistKey]?.['mbid'] !== '') ? `http://musicbrainz.org/artist/${artists[artistKey]?.['mbid']}` : `https://musicbrainz.org/search?query=${track['artist'].replace(
/\s+/g,
'+'
)}&type=artist`,
}
} else {
tracksData[track['track']]['plays']++
}
if (!artistsData[track['artist']]) {
const artistKey = artistSanitizedKey(track['artist'])
artistsData[track['artist']] = {
title: track['artist'],
plays: 1,
mbid: artists[artistKey]?.['mbid'] || '',
url: (artists[artistKey]?.['mbid'] && artists[artistKey]?.['mbid'] !== '') ? `http://musicbrainz.org/artist/${artists[artistKey]?.['mbid']}` : `https://musicbrainz.org/search?query=${track['artist'].replace(
/\s+/g,
'+'
)}&type=artist`,
image: artists[artistSanitizedKey(track['artist'])]?.['image'] || `https://coryd.dev/media/artists/${sanitizeMediaString(track['artist']).replace(/\s+/g, '-').toLowerCase()}.jpg`,
type: 'artist'
}
} else {
artistsData[track['artist']]['plays']++
}
if (!albumsData[track['album']]) {
const albumKey = albumSanitizedKey(track['artist'], track['album'])
albumsData[track['album']] = {
title: track['album'],
artist: track['artist'],
plays: 1,
mbid: albums[albumKey]?.['mbid'] || '',
url: (albums[albumKey]?.['mbid'] && albums[albumSanitizedKey(track['artist'], track['artist'], track['album'])]?.['mbid'] !== '') ? `https://musicbrainz.org/release/${albums[albumKey]?.['mbid']}` : `https://musicbrainz.org/taglookup/index?tag-lookup.artist=${track['artist'].replace(/\s+/g, '+')}&tag-lookup.release=${track['album'].replace(/\s+/g, '+')}`,
image: albums[albumKey]?.['image'] || `https://coryd.dev/media/albums/${sanitizeMediaString(track['artist']).replace(/\s+/g, '-').toLowerCase()}-${sanitizeMediaString(track['album'].replace(/[:\/\\,'']+/g
, '').replace(/\s+/g, '-').toLowerCase())}.jpg`,
type: 'album'
}
} else {
albumsData[track['album']]['plays']++
}
})
const topTracks = objectToArraySorted(tracksData).splice(0, 10)
const topTracksData = {
data: topTracks,
mostPlayed: Math.max(...topTracks.map(track => track.plays))
}
return {
artists: objectToArraySorted(artistsData),
albums: objectToArraySorted(albumsData),
tracks: objectToArraySorted(tracksData),
topTracks: topTracksData,
nowPlaying
}
}
export const buildTracksWithArt = (tracks, artists, albums) => {
tracks.forEach(track => {
track['image'] = albums[albumSanitizedKey(track['artist'], track['album'])]?.['image'] || `https://coryd.dev/media/albums/${sanitizeMediaString(track['artist']).replace(/\s+/g, '-').toLowerCase()}-${sanitizeMediaString(track['album'].replace(/[:\/\\,'']+/g
, '').replace(/\s+/g, '-').toLowerCase())}.jpg`
track['url'] = (artists[artistSanitizedKey(track['artist'])]?.['mbid'] && artists[artistSanitizedKey(track['artist'])]?.['mbid'] !== '') ? `http://musicbrainz.org/artist/${artists[artistSanitizedKey(track['artist'])]?.['mbid']}` : `https://musicbrainz.org/search?query=${track['artist'].replace(
/\s+/g,
'+'
)}&type=artist`
})
return tracks
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
{"track":"Superior","album":"Lifeless Birth","artist":"Necrot","trackNumber":3,"timestamp":"2024-04-12T17:25:38.120+00:00","genre":"death metal","url":"http://musicbrainz.org/artist/0556f527-d02e-440c-b0bb-3e1aa402cf19"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,11 +1,110 @@
import { readFile } from 'fs/promises'
import { buildChart } from './helpers/music.js'
import { createClient } from '@supabase/supabase-js'
import { DateTime } from 'luxon'
export default async function () {
const window = JSON.parse(await readFile('./src/_data/json/scrobbles-window.json', 'utf8'));
const artists = JSON.parse(await readFile('./src/_data/json/artists-map.json', 'utf8'));
const albums = JSON.parse(await readFile('./src/_data/json/albums-map.json', 'utf8'));
const nowPlaying = JSON.parse(await readFile('./src/_data/json/now-playing.json', 'utf8'));
const SUPABASE_URL = process.env.SUPABASE_URL
const SUPABASE_KEY = process.env.SUPABASE_KEY
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY)
return buildChart(window['data'], artists, albums, nowPlaying)
const fetchDataForPeriod = async (startPeriod, fields, table) => {
const PAGE_SIZE = 1000
let rows = []
let rangeStart = 0
while (true) {
const { data, error } = await supabase
.from(table)
.select(fields)
.order('listened_at', { ascending: false })
.gte('listened_at', startPeriod.toUTC().toSeconds())
.range(rangeStart, rangeStart + PAGE_SIZE - 1)
if (error) {
console.error(error)
break
}
rows = rows.concat(data)
if (data.length < PAGE_SIZE) break
rangeStart += PAGE_SIZE
}
return rows
}
const aggregateData = (data, groupByField, groupByType) => {
const aggregation = {}
data.forEach(item => {
const key = item[groupByField]
if (!aggregation[key]) {
if (groupByType === 'track') {
aggregation[key] = {
title: item[groupByField],
plays: 0,
mbid: item['albums']?.mbid || '',
url: item['albums']?.mbid ? `https://musicbrainz.org/release/${item['albums'].mbid}` : `https://musicbrainz.org/search?query=${encodeURIComponent(item['album_name'])}&type=release`,
image: item['albums']?.image || '',
timestamp: item['listened_at'],
type: groupByType
}
} else {
aggregation[key] = {
title: item[groupByField],
plays: 0,
mbid: item[groupByType]?.mbid || '',
url: item[groupByType]?.mbid ? `https://musicbrainz.org/${groupByType === 'albums' ? 'release' : 'artist'}/${item[groupByType].mbid}` : `https://musicbrainz.org/search?query=${encodeURIComponent(item[groupByField])}&type=${groupByType === 'albums' ? 'release' : 'artist'}`,
image: item[groupByType]?.image || '',
type: groupByType
}
}
if (
groupByType === 'track' ||
groupByType === 'albums'
) aggregation[key]['artist'] = item['artist_name']
}
aggregation[key].plays++
})
return Object.values(aggregation).sort((a, b) => b.plays - a.plays)
}
export default async function() {
const periods = {
week: DateTime.now().minus({ days: 7 }).startOf('day'), // Last week
month: DateTime.now().minus({ days: 30 }).startOf('day'), // Last 30 days
threeMonth: DateTime.now().minus({ months: 3 }).startOf('day'), // Last three months
year: DateTime.now().minus({ years: 1 }).startOf('day'), // Last 365 days
}
const results = {}
const selectFields = `
track_name,
artist_name,
album_name,
album_key,
listened_at,
artists (mbid, image),
albums (mbid, image)
`
for (const [period, startPeriod] of Object.entries(periods)) {
const periodData = await fetchDataForPeriod(startPeriod, selectFields, 'listens')
results[period] = {
artists: aggregateData(periodData, 'artist_name', 'artists'),
albums: aggregateData(periodData, 'album_name', 'albums'),
tracks: aggregateData(periodData, 'track_name', 'track')
}
}
const recentData = await fetchDataForPeriod(DateTime.now().minus({ days: 7 }), selectFields, 'listens')
results.recent = {
artists: aggregateData(recentData, 'artist_name', 'artists'),
albums: aggregateData(recentData, 'album_name', 'albums'),
tracks: aggregateData(recentData, 'track_name', 'track')
}
results.nowPlaying = results.recent.tracks[0]
return results
}

View file

@ -1,18 +0,0 @@
import { readFile } from 'fs/promises'
import { buildChart, buildTracksWithArt } from './helpers/music.js'
export default async function () {
const monthChart = JSON.parse(await readFile('./src/_data/json/scrobbles-month-chart.json', 'utf8'));
const threeMonthChart = JSON.parse(await readFile('./src/_data/json/scrobbles-three-month-chart.json', 'utf8'));
const yearChart = JSON.parse(await readFile('./src/_data/json/scrobbles-year-chart.json', 'utf8'));
const artists = JSON.parse(await readFile('./src/_data/json/artists-map.json', 'utf8'));
const albums = JSON.parse(await readFile('./src/_data/json/albums-map.json', 'utf8'));
const recent = JSON.parse(await readFile('./src/_data/json/scrobbles-window.json', 'utf8'))['data'].reverse().splice(0,10)
return {
recent: buildTracksWithArt(recent, artists, albums),
month: buildChart(monthChart['data'], artists, albums),
threeMonth: buildChart(threeMonthChart['data'], artists, albums),
year: buildChart(yearChart['data'], artists, albums),
}
}

View file

@ -1,25 +0,0 @@
import { readFile } from 'fs/promises'
import { buildChart } from './helpers/music.js'
import { DateTime } from 'luxon'
export default async function () {
const currentDate = DateTime.now()
const lastWeek = currentDate.minus({ weeks: 1 })
const artists = JSON.parse(await readFile('./src/_data/json/artists-map.json', 'utf8'));
const albums = JSON.parse(await readFile('./src/_data/json/albums-map.json', 'utf8'));
const chartData = JSON.parse(await readFile('./src/_data/json/weekly-top-artists-chart.json', 'utf8'))
const artistChart = buildChart(chartData['data'], artists, albums)['artists'].splice(0, 8)
let content = 'My top artists for the week: '
artistChart.forEach((artist, index) => {
content += `${artist['title']} @ ${artist['plays']} play${parseInt(artist['plays']) > 1 ? 's' : ''}`
if (index !== artistChart.length - 1) content += ', '
})
content += ' #Music'
return [{
title: content,
url: `https://coryd.dev/now?ts=${lastWeek.year}-${lastWeek.weekNumber}#artists`,
date: DateTime.fromMillis(parseInt(chartData['timestamp'])).toISO(),
description: `My top artists for the last week.<br/><br/>`
}]
}

View file

@ -19,16 +19,16 @@ layout: default
</div>
</div>
<div id="artists-window">
{% render "partials/now/media-grid.liquid", data:music.artists, shape: "square", count: 8, loading: "eager" %}
{% render "partials/now/media-grid.liquid", data:music.week.artists, shape: "square", count: 8, loading: "eager" %}
</div>
<div class="hidden" id="artists-month">
{% render "partials/now/media-grid.liquid", data:musicCharts.month.artists, shape: "square", count: 8 %}
{% render "partials/now/media-grid.liquid", data:music.month.artists, shape: "square", count: 8 %}
</div>
<div class="hidden" id="artists-three-months">
{% render "partials/now/media-grid.liquid", data:musicCharts.threeMonth.artists, shape: "square", count: 8 %}
{% render "partials/now/media-grid.liquid", data:music.threeMonth.artists, shape: "square", count: 8 %}
</div>
<div class="hidden" id="artists-year">
{% render "partials/now/media-grid.liquid", data:musicCharts.year.artists, shape: "square", count: 8 %}
{% render "partials/now/media-grid.liquid", data:music.year.artists, shape: "square", count: 8 %}
</div>
<div class="section-header-wrapper">
<h2 id="albums" class="section-header flex-centered">
@ -43,16 +43,16 @@ layout: default
</div>
</div>
<div id="albums-window">
{% render "partials/now/media-grid.liquid", data:music.albums, shape: "square", count: 8 %}
{% render "partials/now/media-grid.liquid", data:music.week.albums, shape: "square", count: 8 %}
</div>
<div class="hidden" id="albums-month">
{% render "partials/now/media-grid.liquid", data:musicCharts.month.albums, shape: "square", count: 8 %}
{% render "partials/now/media-grid.liquid", data:music.month.albums, shape: "square", count: 8 %}
</div>
<div class="hidden" id="albums-three-months">
{% render "partials/now/media-grid.liquid", data:musicCharts.threeMonth.albums, shape: "square", count: 8 %}
{% render "partials/now/media-grid.liquid", data:music.threeMonth.albums, shape: "square", count: 8 %}
</div>
<div class="hidden" id="albums-year">
{% render "partials/now/media-grid.liquid", data:musicCharts.year.albums, shape: "square", count: 8 %}
{% render "partials/now/media-grid.liquid", data:music.year.albums, shape: "square", count: 8 %}
</div>
<div class="section-header-wrapper">
<h2 id="tracks" class="section-header flex-centered">
@ -68,19 +68,19 @@ layout: default
</div>
</div>
<div id="tracks-recent">
{% render "partials/now/tracks-recent.liquid", data:musicCharts.recent %}
{% render "partials/now/tracks-recent.liquid", data:music.recent.tracks %}
</div>
<div class="hidden" id="tracks-window">
{% render "partials/now/track-chart.liquid", data:music.topTracks.data, mostPlayed:music.topTracks.mostPlayed %}
{% render "partials/now/track-chart.liquid", data:music.week.tracks, mostPlayed:music.week.tracks[0].plays %}
</div>
<div class="hidden" id="tracks-month">
{% render "partials/now/track-chart.liquid", data:musicCharts.month.topTracks.data, mostPlayed:musicCharts.month.topTracks.mostPlayed %}
{% render "partials/now/track-chart.liquid", data:music.month.tracks, mostPlayed:music.month.tracks[0].plays %}
</div>
<div class="hidden" id="tracks-three-months">
{% render "partials/now/track-chart.liquid", data:musicCharts.threeMonth.topTracks.data, mostPlayed:musicCharts.threeMonth.topTracks.mostPlayed %}
{% render "partials/now/track-chart.liquid", data:music.threeMonth.tracks, mostPlayed:music.threeMonth.tracks[0].plays %}
</div>
<div class="hidden" id="tracks-year">
{% render "partials/now/track-chart.liquid", data:musicCharts.year.topTracks.data, mostPlayed:musicCharts.year.topTracks.mostPlayed %}
{% render "partials/now/track-chart.liquid", data:music.year.tracks, mostPlayed:music.year.tracks[0].plays %}
</div>
{% render "partials/now/album-releases.liquid", albumReleases:albumReleases %}
<h2 id="books" class="section-header flex-centered">

View file

@ -1,12 +1,12 @@
{% if data.size > 0 %}
<div class="music-chart">
{% for item in data limit: 10 %}
{% capture alt %}{{ item.track | escape }} by {{ item.artist }}{% endcapture %}
{% capture alt %}{{ item.title | escape }} by {{ item.artist }}{% endcapture %}
<div class="item">
<div class="meta">
<img src="https://coryd.dev/.netlify/images/?url={{ item.image }}&fit=cover&w=64&h=64&fm=webp&q=65" class="image-banner" alt="{{ alt }}" loading="lazy" decoding="async" width="64" height="64" />
<div class="meta-text">
<div class="title">{{ item.track }}</div>
<div class="title">{{ item.title }}</div>
<div class="subtext">
<a href="{{ item.url }}">{{ item.artist }}</a>
</div>

View file

@ -1,13 +0,0 @@
---
layout: null
eleventyExcludeFromCollections: true
permalink: /feeds/weekly-artist-chart
---
{% render "partials/feeds/rss.liquid"
permalink:"/feeds/weekly-artist-chart"
title:"Weekly artist chart • Cory Dransfeldt"
description:"The top 8 artists I've listened to this week."
data:weeklyArtistChart
updated:weeklyArtistChart[0].date
site:site
%}

View file

@ -1,7 +1,7 @@
---
layout: default
---
{% render "partials/home/now.liquid" status:status, artists:music.artists, books:books, tv:tv %}
{% render "partials/home/now.liquid" status:status, artists:music.week.artists, books:books, tv:tv %}
{% render "partials/home/posts.liquid" icon: "star", title: "Featured", postData:collections.posts, postType: "featured" %}
{% assign posts = collections.posts | reverse %}
{% render "partials/home/posts.liquid" icon: "clock-2", title: "Recent posts", postData:posts %}

View file

@ -3,7 +3,7 @@ title: About
layout: default
permalink: /about.html
---
{%- assign artist = music.artists | first -%}
{%- assign artist = music.week.artists | first -%}
{%- assign book = books | bookStatus: 'started' | reverse | first -%}
{%- assign show = tv | first -%}
<div class="avatar-wrapper flex-centered">

View file

@ -20,7 +20,7 @@ description: "See what I'm doing now."
</p>
<p>
{% tablericon "headphones" "Listening to" %}
Listening to tracks like <strong class="highlight-text">{{ music.nowPlaying.track }}</strong> by <strong class="highlight-text">{{ music.nowPlaying.artist }}</strong>.
Listening to tracks like <strong class="highlight-text">{{ music.nowPlaying.title }}</strong> by <strong class="highlight-text">{{ music.nowPlaying.artist }}</strong>.
</p>
<p>
{% tablericon "needle" "Getting tattooed" %}