Luciano Mammino (@loige)
2021-09-30
Get these slides!
Photo by Darius Bashar on Unsplash
A random song you haven't listened to
in years pops into your head...
The Ohrwurm! 👂🐛
It doesn't matter what you do all day...
It keeps coming back to you! 🐛
Photo by Attentie Attentie on Unsplash
And now you want to listen to it!
Photo by Volodymyr Hryshchenko on Unsplash
But, what if you can't remember
the title or the author?!
Photo by Tachina Lee on Unsplash
THERE MUST BE A WAY TO REMEMBER!
Photo by Marius Niveri on Unsplash
Today, I'll tell you how I solved this problem using
- Last.fm API
- Node.js
- Async Iterators
Photo by Quinton Coetzee on Unsplash
I'm Luciano (🇮🇹🍕🍝) 👋
Senior Architect @ fourTheorem (Dublin 🇮🇪) 👨💻
Co-Author of Node.js Design Patterns 👉
Accelerated Serverless | AI as a Service | Platform Modernisation
We are hiring: do you want to work with us?
Luciano - scrobbling since 12 Feb 2007
~250k scrobbles... that song must be there!
~5k pages of history &
no search functionality! 😓
But there's an API!
https://www.last.fm/api
curl "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=loige&api_key=${API_KEY}&format=json" | jq .
import querystring from 'querystring'
import axios from 'axios'
const query = querystring.stringify({
method: 'user.getrecenttracks',
user: 'loige',
api_key: process.env.API_KEY,
format: 'json'
})
const url = `https://ws.audioscrobbler.com/2.0/?${query}`
const response = await axios.get(url)
console.log(response.data)
We are getting a "paginated" response with 50 tracks per page
but there are 51 here! 🤔
How do we fetch the next pages?
(let's ignore this for now...)
let page = 1
while (true) {
const query = querystring.stringify({
method: 'user.getrecenttracks',
user: 'loige',
api_key: process.env.API_KEY,
format: 'json',
page
})
const url = `https://ws.audioscrobbler.com/2.0/?${query}`
const response = await axios.get(url)
console.log(response.data)
if (page === Number(response.data.recenttracks['@attr'].totalPages)) {
break // it's the last page!
}
page++
}
// ...
for (const track of response.data.recenttracks.track) {
console.log(
track.date?.['#text'],
`${track.artist['#text']} - ${track.name}`
)
}
console.log('--- end page ---')
// ...
* Note that page size here is 10 tracks per page
Every page has a song with undefined time...
This is the song I am currently listening to!
It appears at the top of every page.
Sometimes there are duplicated tracks between pages... 😨
...
tracks (newest to oldest)
Page1
Page2
...
Page1
Page2
new track
moved from page 1 to page 2
...*
tracks (newest to oldest)
Page1
before t1
(page 1 "to" t1)
t1
t2
before t2
(page 1 "to" t2)
* we are done when we get an empty page (or num pages is 1)
to ... from
let to
while (true) {
const query = querystring.stringify({
method: 'user.getrecenttracks',
user: 'loige',
api_key: process.env.API_KEY,
format: 'json',
limit: '10',
to
})
const url = `https://ws.audioscrobbler.com/2.0/?${query}`
const response = await axios.get(url)
const tracks = response.data.recenttracks.track
console.log(
`--- ↓ page to ${to}`,
`remaining pages: ${response.data.recenttracks['@attr'].totalPages} ---`
)
for (const track of tracks) {
console.log(track.date?.uts, `${track.artist['#text']} - ${track.name}`)
}
if (response.data.recenttracks['@attr'].totalPages <= 1) {
break // it's the last page!
}
const lastTrackInPage = tracks[tracks.length - 1]
to = lastTrackInPage.date.uts
}
The track of the last timestamp becomes the boundary for the next page
const reader = LastFmRecentTracks({
apikey: process.env.API_KEY,
user: 'loige'
})
// callbacks
reader.readPages(
(page) => { /* ... */ }, // on page
(err) => { /* ... */} // on completion (or error)
)
const reader = LastFmRecentTracks({
apikey: process.env.API_KEY,
user: 'loige'
})
// event emitter
reader.read()
reader.on('page', (page) => { /* ... */ })
reader.on('completed', (err) => { /* ... */ })
const reader = LastFmRecentTracks({
apikey: process.env.API_KEY,
user: 'loige'
})
// streams <3
reader.pipe(/* transform or writable stream here */)
reader.on('end', () => { /* ... */ })
reader.on('error', () => { /* ... */ })
import { pipeline } from 'stream'
const reader = LastFmRecentTracks({
apikey: process.env.API_KEY,
user: 'loige'
})
// streams pipeline <3 <3
pipeline(
reader,
yourProcessingStream,
(err) => {
// handle completion or err
}
)
const reader = LastFmRecentTracks({
apikey: process.env.API_KEY,
user: 'loige'
})
// ASYNC ITERATORS!
for await (const page of reader) {
/* ... */
}
// ... do more stuff when all the data is consumed
const reader = LastFmRecentTracks({
apikey: process.env.API_KEY,
user: 'loige'
})
// ASYNC ITERATORS WITH ERROR HANDLING!
try {
for await (const page of reader) {
/* ... */
}
} catch (err) {
// handle errors
}
// ... do more stuff when all the data is consumed
function createCountdown (from) {
let nextVal = from
return {
next () {
if (nextVal < 0) {
return { done: true }
}
return {
done: false,
value: nextVal--
}
}
}
}
const countdown = createCountdown(3)
console.log(countdown.next())
// { done: false, value: 3 }
console.log(countdown.next())
// { done: false, value: 2 }
console.log(countdown.next())
// { done: false, value: 1 }
console.log(countdown.next())
// { done: false, value: 0 }
console.log(countdown.next())
// { done: true }
function * createCountdown (from) {
for (let i = from; i >= 0; i--) {
yield i
}
}
const countdown = createCountdown(3)
console.log(countdown.next())
// { done: false, value: 3 }
console.log(countdown.next())
// { done: false, value: 2 }
console.log(countdown.next())
// { done: false, value: 1 }
console.log(countdown.next())
// { done: false, value: 0 }
console.log(countdown.next())
// { done: true, value: undefined }
*Symbol.iterator
function createCountdown (from) {
let nextVal = from
return {
[Symbol.iterator]: () => ({
next () {
if (nextVal < 0) {
return { done: true }
}
return { done: false, value: nextVal-- }
}
})
}
}
function createCountdown (from) {
return {
[Symbol.iterator]: function * () {
for (let i = from; i >= 0; i--) {
yield i
}
}
}
}
const countdown = createCountdown(3)
for (const value of countdown) {
console.log(value)
}
// 3
// 2
// 1
// 0
import { setTimeout } from 'timers/promises'
function createAsyncCountdown (from, delay = 1000) {
let nextVal = from
return {
async next () {
await setTimeout(delay)
if (nextVal < 0) {
return { done: true }
}
return { done: false, value: nextVal-- }
}
}
}
const countdown = createAsyncCountdown(3)
console.log(await countdown.next())
// { done: false, value: 3 }
console.log(await countdown.next())
// { done: false, value: 2 }
console.log(await countdown.next())
// { done: false, value: 1 }
console.log(await countdown.next())
// { done: false, value: 0 }
console.log(await countdown.next())
// { done: true }
import { setTimeout } from 'timers/promises'
// async generators "produce" async iterators!
async function * createAsyncCountdown (from, delay = 1000) {
for (let i = from; i >= 0; i--) {
await setTimeout(delay)
yield i
}
}
*Symbol.asyncIterator
import { setTimeout } from 'timers/promises'
function createAsyncCountdown (from, delay = 1000) {
return {
[Symbol.asyncIterator]: async function * () {
for (let i = from; i >= 0; i--) {
await setTimeout(delay)
yield i
}
}
}
}
(We don't need to specify Symbol.asyncIterator explicitly!)
import { setTimeout } from 'timers/promises'
// async generators "produce" async iterators
// (and iterables!)
async function * createAsyncCountdown (from, delay = 1000) {
for (let i = from; i >= 0; i--) {
await setTimeout(delay)
yield i
}
}
const countdown = createAsyncCountdown(3)
for await (const value of countdown) {
console.log(value)
}
import querystring from 'querystring'
import axios from 'axios'
async function * createLastFmRecentTracks (apiKey, user) {
let to
while (true) {
const query = querystring.stringify({
method: 'user.getrecenttracks',
user,
api_key: apiKey,
format: 'json',
to
})
const url = `https://ws.audioscrobbler.com/2.0/?${query}`
const response = await axios.get(url)
const tracks = response.data.recenttracks.track
yield tracks
if (response.data.recenttracks['@attr'].totalPages <= 1) {
break // it's the last page!
}
const lastTrackInPage = tracks[tracks.length - 1]
to = lastTrackInPage.date.uts
}
}
const recentTracks = createLastFmRecentTracks(
process.env.API_KEY,
'loige'
)
for await (const page of recentTracks) {
console.log(page)
}
async function main () {
const recentTracks = createLastFmRecentTracks(
process.env.API_KEY,
'loige'
)
for await (const page of recentTracks) {
for (const track of page) {
if (track.name.toLowerCase().includes('dark')) {
console.log(`${track.artist['#text']} - ${track.name}`)
}
}
}
}
OMG! This is the song! 😱
...from 8 years ago!
npm install scrobbles
for await (const _ of createAsyncCountdown(1_000_000)) {
console.log("THANK YOU! 😍")
}