Skip to content

Instantly share code, notes, and snippets.

@ZackBoe
Last active September 14, 2020 21:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ZackBoe/1085bcf3126b1650adc2188cb5fb37a9 to your computer and use it in GitHub Desktop.
Save ZackBoe/1085bcf3126b1650adc2188cb5fb37a9 to your computer and use it in GitHub Desktop.
Spotify Recently Played scrobbler for Maloja
const fetch = require('node-fetch');
const qs = require('querystring');
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
const db = low(new FileSync('tracks.json', {
serialize: (obj) => JSON.stringify(obj),
deserialize: (data) => JSON.parse(data)
}))
db.defaults({ tracks: [] }).write()
require('dotenv').config()
const {
SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN, MALOJA_KEY, HEALTHCHECK, MALOJA_SUBMIT
} = process.env
const SPOTIFY_BASIC = Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')
const SPOTIFY_SCOPES = ['user-read-recently-played', 'user-read-currently-playing']
let after = Date.now() - (60000*15)
async function getAuthToken(){
token = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${SPOTIFY_BASIC}`
},
body: qs.stringify({
grant_type: 'refresh_token',
refresh_token: SPOTIFY_REFRESH_TOKEN
}),
})
.then(res => {
if (res.status === 200) return res.json()
else throw new Error(res.status)
})
.catch(err => {
throw new Error(err)
})
return token.access_token
}
async function getCurrentUserName(){
const currentUser = await fetch(`https://api.spotify.com/v1/me`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await getAuthToken()}`
},
})
.then(res => {
if (res.status === 200) return res.json()
else throw new Error(res.status)
})
.catch(err => {
throw new Error(err)
})
return currentUser.display_name
}
async function getRecentlyPlayed(){
const recentlyPlayed = await fetch(`https://api.spotify.com/v1/me/player/recently-played?limit=50${after ? `&after=${after.toString()}` : ''}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await getAuthToken()}`
},
})
.then(res => {
if (res.status === 200) return res.json()
else throw new Error(res.status)
})
.catch(err => {
throw new Error(err)
})
return recentlyPlayed
}
async function scrobbleTrack(track){
// Maloja's native api wasn't accepting scrobbles properly, so using ListenBrainz API endpoint
const maloja = await fetch(MALOJA_SUBMIT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': MALOJA_KEY
},
body: JSON.stringify({
'listen_type': 'single',
'payload': [{'track_metadata': {'artist_name': track.artists,'track_name': track.title}}]
})
})
.then(res => {
if (res.status === 200) return res.json()
else throw new Error(res.status)
})
.catch(err => {
throw new Error(err)
})
db.get('tracks').push({ id: track.id, artists: track.artists, title: track.title, played_at: track.played_at }).write()
console.log(`Scrobbled '${track.artists} - ${track.title}' to Maloja`)
}
(async () => {
const tracks = await getRecentlyPlayed()
if(tracks?.items?.length > 0) {
console.log(`Got ${tracks.items.length} tracks from Spotify user '${await getCurrentUserName()}' at ${Date.now()}`)
after = new Date(tracks.items[0].played_at).getTime()
tracksToScrobble = tracks.items.map(item => {
return {
id: item.track.id,
played_at: new Date(item.played_at).getTime(),
artists: [...item.track.artists.map(artist => artist.name)].join(';'),
title: item.track.name
}
})
// Spotify likes to count a single play, skipped tracks, or paused tracks as multiple plays sometimes?
// Remove duplicates https://stackoverflow.com/a/56757215/1810897
tracksToScrobble = tracksToScrobble.filter((v,i,a)=>a.findIndex(t=>(t.title === v.title && t.artists === v.artists))===i)
tracksToScrobble.forEach(async (track) => {
let alreadyScrobbled = await db.get('tracks').find({ id: track.id, played_at: track.played_at }).value()
if(!alreadyScrobbled) scrobbleTrack(track)
})
} else after = Date.now()
fetch(HEALTHCHECK).catch(err => { throw new Error(err)})
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment