Finding a lost song
with Node.js & async iterators

Luciano Mammino (@loige)

Sailsconf 2021
2021-06-24

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!

And now you want to listen to it!

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

Let me introduce myself first...

I'm Luciano (🇮🇹🍕🍝) 👋

Senior Architect @ fourTheorem (Dublin 🇮🇪) 👨‍💻

Co-Author of Node.js Design Patterns  👉

Connect with me:
 

  loige.co (blog)

  @loige (twitter)

  loige (twitch)

  lmammino (github)

We are business focused technologists that deliver.


Accelerated Serverless | AI as a Service | Platform Modernisation

We are hiring: do you want to work with us?

There was this song in my mind...

I could only remember some random parts and the word "dark" (probably in the title)

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

Let's give it a shot

curl "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=loige&api_key=${API_KEY}&format=json" | jq .

It works! 🥳

Let's convert this to JavaScript

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++
}

Seems good!

Let's look at the tracks...

// ...
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... 😨

The "sliding windows" problem 😩

...

tracks (newest to oldest)

image/svg+xml
image/svg+xml

Page1

Page2

...

image/svg+xml
image/svg+xml

Page1

Page2

new track

moved from page 1 to page 2

Time based windows 😎

...*

tracks (newest to oldest)

image/svg+xml
image/svg+xml

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)

image/svg+xml
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

We have a working solution! 🎉
Can we generalise it?

We know how to iterate over every page/track.
How do we expose this information?

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

How can we build an async iterator? 🧐

Meet the iteration protocols!

The iterator protocol

An object is an iterator if it has a next() method. Every time you call it, it returns an object with the keys done (boolean) and value.

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 }

Generator functions "produce" iterators!

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 }

The iterable protocol

An object is iterable if it implements the @@iterator* method, a zero-argument function that returns an iterator.

*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

OK. So far this is all synchronous iteration.
What about async? 🙄

The async iterator protocol

An object is an async iterator if it has a next() method. Every time you call it, it returns a promise that resolves to an object with the keys done (boolean) and value.

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
  }
}

The async iterable protocol

An object is an async iterable if it implements the @@asyncIterator* method, a zero-argument function that returns an async iterator.

*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
      }
    }
  }
}
const countdown = createAsyncCountdown(3)

for await (const value of countdown) {
  console.log(value)
}

Now we know how to make our LastFmRecentTracks an Async Iterable 🤩

function createLastFmRecentTracks (apiKey, user) {
  return {
    [Symbol.asyncIterator]: async function * () {
      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)
}

Let's search for all the songs that contain the word "dark" in their title! 🧐

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!

For a more serious package that allows you to fetch data from Last.fm:

npm install scrobbles

Cover picture by Eric Nopanen on Unsplash
Thanks to Jacek Spera, @eoins, @pelger, @gbinside, @ManuEomm  for reviews and suggestions.

for await (const _ of createAsyncCountdown(1_000_000)) {
  console.log("THANK YOU! 😍")
}

Finding a lost song with Node.js and async iterators

By Luciano Mammino

Finding a lost song with Node.js and async iterators

Did you ever get that feeling when a random song pops into your brain and you can’t get rid of it? Well, that happened to me recently and I couldn’t even remember the title of the damn song! In this talk, I want to share with you the story of how I was able to recover the details of the song by navigating some music-related APIs using JavaScript, Node.js and the magic of async iterators!

  • 3,946