const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3') const _ = require('lodash') const { AssetCache } = require('@11ty/eleventy-fetch') const artistAliases = require('./json/artist-aliases.json') const titleCaseExceptions = require('./json/title-case-exceptions.json') const { getReadableData } = require('../utils/aws') const aliasArtist = (artist) => { const aliased = artistAliases.aliases.find((alias) => alias.aliases.includes(artist)) if (aliased) artist = aliased.artist return artist } const 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() } const titleCase = (string) => { if (!string) return '' return string .toLowerCase() .split(' ') .map((word, i) => { return titleCaseExceptions.exceptions.includes(word) && i !== 0 ? word : word.charAt(0).toUpperCase().concat(word.substring(1)) }) .join(' ') } const diffTracks = (cache, tracks) => { const trackCompareSet = Object.values(tracks).sort((a, b) => a.time - b.time) const cacheCompareSet = Object.values(cache).sort((a, b) => a.time - b.time) const diffedTracks = {} const ONE_HOUR_MS = 3600000 const tracksOneHour = [] let trackIndex = 0 let trackTimer = 0 while (trackTimer < ONE_HOUR_MS) { trackTimer = trackTimer + parseInt(trackCompareSet[trackIndex].duration) tracksOneHour.push(trackCompareSet[trackIndex]) trackIndex++ } const comparedTracks = _.differenceWith( tracksOneHour, cacheCompareSet.slice(-tracksOneHour.length), (a, b) => _.isEqual(a.id, b.id) ) for (let i = 0; i < comparedTracks.length; i++) diffedTracks[`${comparedTracks[i]?.id}-${comparedTracks[i].time}`] = comparedTracks[i] return diffedTracks } const formatTracks = (tracks, time) => { let formattedTracks = {} 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']) if (!formattedTracks[track.attributes.name]) { formattedTracks[track.attributes.name] = { name: trackFormatted, artist: artistFormatted, album: albumFormatted, 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, time, duration: track.attributes['durationInMillis'], } } else { formattedTracks[track.attributes.name].plays++ } }) return formattedTracks } const deriveCharts = (tracks) => { const charts = { artists: {}, albums: {}, } const tracksForLastWeek = Object.values(tracks).filter((track) => { const currentDate = new Date() const currentDateTime = new Date().getTime() const lastWeek = new Date(currentDate.setDate(currentDate.getDate() - 7)) const lastWeekDateTime = lastWeek.getTime() const trackDateTime = new Date(track.time).getTime() return trackDateTime <= currentDateTime && trackDateTime > lastWeekDateTime }) tracksForLastWeek.forEach((track) => { if (!charts.artists[track.artist]) { charts.artists[track.artist] = { artist: track.artist, 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 APPLE_TOKEN = APPLE_TOKEN_RESPONSE['music-token'] const asset = new AssetCache('recent_tracks_data') const PAGE_SIZE = 30 const PAGES = 10 const time = Number(new Date()) let charts = { artists: {}, albums: {}, } let CURRENT_PAGE = 0 let res = [] let hasNextPage = true if (asset.isCacheValid('1h')) return await asset.getCachedValue() 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&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++ } const cachedTracksOutput = await client.send( new GetObjectCommand({ Bucket: WASABI_BUCKET, Key: 'music.json', }) ) const cachedTracksData = getReadableData(cachedTracksOutput.Body) const cachedTracks = await cachedTracksData.then((tracks) => JSON.parse(tracks)).catch() const updatedCache = { ...cachedTracks, ...diffTracks(cachedTracks, formatTracks(res, time)), } charts = deriveCharts(updatedCache) charts.artists = Object.values(charts.artists) .sort((a, b) => b.plays - a.plays) .splice(0, 8) charts.albums = Object.values(charts.albums) .sort((a, b) => b.plays - a.plays) .splice(0, 8) await client.send( new PutObjectCommand({ Bucket: WASABI_BUCKET, Key: 'music.json', Body: JSON.stringify(updatedCache), }) ) await asset.save(charts, 'json') return charts }