Luciano Mammino (@loige)
2022-05-18
Get these slides!
Photo by Darius Bashar on Unsplash
A random song you haven't listened to
in years pops into your head...
👂🐛
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!
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 { request } from 'undici'
const query = new URLSearchParams({
method: 'user.getrecenttracks',
user: 'loige',
api_key: process.env.API_KEY,
format: 'json'
})
const url = `https://ws.audioscrobbler.com/2.0/?${query}`
const { body } = await request(url)
const data = await body.json()
console.log(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 = new URLSearchParams({
method: 'user.getrecenttracks',
user: 'loige',
api_key: process.env.API_KEY,
format: 'json',
page
})
const url = `https://ws.audioscrobbler.com/2.0/?${query}`
const { body } = await request(url)
const data = await body.json()
console.log(data)
if (page === Number(data.recenttracks['@attr'].totalPages)) {
break // it's the last page!
}
page++
}
// ...
for (const track of 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 = new URLSearchParams({
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 { body } = await request(url)
const data = await body.json()
const tracks = data.recenttracks.track
console.log(
`--- ↓ page to ${to}`,
`remaining pages: ${data.recenttracks['@attr'].totalPages} ---`
)
for (const track of tracks) {
console.log(track.date?.uts, `${track.artist['#text']} - ${track.name}`)
}
if (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)
)
// event emitter
reader.read()
reader.on('page', (page) => { /* ... */ })
reader.on('completed', (err) => { /* ... */ })
// streams ❤️
reader.pipe(/* transform or writable stream here */)
reader.on('end', () => { /* ... */ })
reader.on('error', () => { /* ... */ })
// streams pipeline ❤️❤️
pipeline(
reader,
yourProcessingStream,
(err) => {
// handle completion or err
}
)
// ASYNC ITERATORS! 😵
for await (const page of reader) {
/* ... */
}
// ... do more stuff when all the
// data is consumed
// 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
An object that acts as a cursor to iterate over blocks of data sequentially
An object that contains data that can be iterated over sequentially
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 }
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
}
}
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 { request } from 'undici'
async function* createLastFmRecentTracks (apiKey, user) {
let to = ''
while (true) {
const query = new URLSearchParams({
method: 'user.getrecenttracks',
user,
api_key: apiKey,
format: 'json',
to
})
const url = `https://ws.audioscrobbler.com/2.0/?${query}`
const { body } = await request(url)
const data = await body.json()
const tracks = data.recenttracks.track
yield tracks
if (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)
}
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 9 years ago!
Photo by Lee Campbell on Unsplash
❤️ Thanks to @goldbergyoni, Jacek Spera, @eoins, @pelger, @gbinside, @ManuEomm, @simonplend for reviews and suggestions.
for await (const _ of createAsyncCountdown(1_000_000)) {
console.log("THANK YOU! 😍")
}