Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Finding a lost song with Node.js and async iter...

Finding a lost song with Node.js and async iterators - EnterJS 2021

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!

Luciano Mammino

September 30, 2021
Tweet

More Decks by Luciano Mammino

Other Decks in Programming

Transcript

  1. Finding a lost song with Node.js & async iterators Luciano

    Mammino ( ) @loige 2021-09-30 loige.link/enter-iterators 1
  2. Photo by on Darius Bashar Unsplash A random song you

    haven't listened to in years pops into your head... The Ohrwurm! 👂🐛 3
  3. It doesn't matter what you do all day... It keeps

    coming back to you! 🐛 Photo by on Attentie Attentie Unsplash 4
  4. And now you want to listen to it! Photo by

    on Volodymyr Hryshchenko Unsplash 5
  5. But, what if you can't remember the title or the

    author?! Photo by on Tachina Lee Unsplash 6
  6. Today, I'll tell you how I solved this problem using

    - Last.fm API - Node.js - Async Iterators Photo by on Quinton Coetzee Unsplash 8
  7. Let me introduce myself first... I'm Luciano ( 🍕🍝) 👋

    Senior Architect @ fourTheorem (Dublin ) 9
  8. Let me introduce myself first... I'm Luciano ( 🍕🍝) 👋

    Senior Architect @ fourTheorem (Dublin ) nodejsdp.link Co-Author of Node.js Design Patterns 👉 9
  9. Let me introduce myself first... I'm Luciano ( 🍕🍝) 👋

    Senior Architect @ fourTheorem (Dublin ) nodejsdp.link Co-Author of Node.js Design Patterns 👉 Connect with me: (blog) (twitter) (twitch) (github) loige.co @loige loige lmammino 9
  10. 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 loige 10
  11. I could only remember some random parts and the word

    "dark" (probably in the title) loige 12
  12. 13

  13. 14

  14. 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) loige 22
  15. loige We are getting a "paginated" response with 50 tracks

    per page but there are 51 here! 🤔 23
  16. loige We are getting a "paginated" response with 50 tracks

    per page but there are 51 here! 🤔 (let's ignore this for now...) 23
  17. loige 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...) 23
  18. 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! } loige 25
  19. 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! } loige 25
  20. 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! } loige 25
  21. 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! } loige 25
  22. 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! } loige 25
  23. 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! } loige 25
  24. // ... for (const track of response.data.recenttracks.track) { console.log( track.date?.['#text'],

    `${track.artist['#text']} - ${track.name}` ) } console.log('--- end page ---') // ... loige 28
  25. loige * 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. 29
  26. loige * Note that page size here is 10 tracks

    per page Sometimes there are duplicated tracks between pages... 😨 29
  27. loige ... tracks (newest to oldest) 31 Page1 Page2 ...

    Page1 Page2 new track moved from page 1 to page 2
  28. loige ...* tracks (newest to oldest) 34 * we are

    done when we get an empty page (or num pages is 1) to ... from
  29. loige ...* tracks (newest to oldest) 34 Page1 * we

    are done when we get an empty page (or num pages is 1) to ... from
  30. loige ...* tracks (newest to oldest) 34 Page1 t1 *

    we are done when we get an empty page (or num pages is 1) to ... from
  31. loige ...* tracks (newest to oldest) 34 Page1 before t1

    (page 1 "to" t1) t1 * we are done when we get an empty page (or num pages is 1) to ... from
  32. loige ...* tracks (newest to oldest) 34 Page1 before t1

    (page 1 "to" t1) t1 t2 * we are done when we get an empty page (or num pages is 1) to ... from
  33. loige ...* tracks (newest to oldest) 34 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
  34. 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 } loige 35
  35. 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 } loige 35
  36. 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 } loige 35
  37. 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 } loige 35
  38. 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 } loige 35
  39. We know how to iterate over every page/track. How do

    we expose this information? loige 38
  40. const reader = LastFmRecentTracks({ apikey: process.env.API_KEY, user: 'loige' }) //

    callbacks reader.readPages( (page) => { /* ... */ }, // on page (err) => { /* ... */} // on completion (or error) ) loige 39
  41. const reader = LastFmRecentTracks({ apikey: process.env.API_KEY, user: 'loige' }) //

    event emitter reader.read() reader.on('page', (page) => { /* ... */ }) reader.on('completed', (err) => { /* ... */ }) loige 40
  42. 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', () => { /* ... */ }) loige 41
  43. 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 } ) loige 42
  44. 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 loige 43
  45. 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 loige 44
  46. 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. loige 47
  47. function createCountdown (from) { let nextVal = from return {

    next () { if (nextVal < 0) { return { done: true } } return { done: false, value: nextVal-- } } } } loige 48
  48. function createCountdown (from) { let nextVal = from return {

    next () { if (nextVal < 0) { return { done: true } } return { done: false, value: nextVal-- } } } } loige 48
  49. function createCountdown (from) { let nextVal = from return {

    next () { if (nextVal < 0) { return { done: true } } return { done: false, value: nextVal-- } } } } loige 48
  50. function createCountdown (from) { let nextVal = from return {

    next () { if (nextVal < 0) { return { done: true } } return { done: false, value: nextVal-- } } } } loige 48
  51. function createCountdown (from) { let nextVal = from return {

    next () { if (nextVal < 0) { return { done: true } } return { done: false, value: nextVal-- } } } } loige 48
  52. 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 } loige 49
  53. function * createCountdown (from) { for (let i = from;

    i >= 0; i--) { yield i } } loige 51
  54. function * createCountdown (from) { for (let i = from;

    i >= 0; i--) { yield i } } loige 51
  55. function * createCountdown (from) { for (let i = from;

    i >= 0; i--) { yield i } } loige 51
  56. 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 } loige 52
  57. The iterable protocol An object is iterable if it implements

    the @@iterator* method, a zero-argument function that returns an iterator. loige *Symbol.iterator 53
  58. function createCountdown (from) { let nextVal = from return {

    [Symbol.iterator]: () => ({ next () { if (nextVal < 0) { return { done: true } } return { done: false, value: nextVal-- } } }) } } loige 54
  59. function createCountdown (from) { let nextVal = from return {

    [Symbol.iterator]: () => ({ next () { if (nextVal < 0) { return { done: true } } return { done: false, value: nextVal-- } } }) } } loige 54
  60. function createCountdown (from) { let nextVal = from return {

    [Symbol.iterator]: () => ({ next () { if (nextVal < 0) { return { done: true } } return { done: false, value: nextVal-- } } }) } } loige 54
  61. function createCountdown (from) { return { [Symbol.iterator]: function * ()

    { for (let i = from; i >= 0; i--) { yield i } } } } loige 55
  62. const countdown = createCountdown(3) for (const value of countdown) {

    console.log(value) } // 3 // 2 // 1 // 0 loige 56
  63. 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. loige 58
  64. 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-- } } } } loige 59
  65. 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-- } } } } loige 59
  66. 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-- } } } } loige 59
  67. 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 } loige 60
  68. 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 } loige 60
  69. 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 } } loige 62
  70. 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 } } loige 62
  71. 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 } } loige 62
  72. 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. loige *Symbol.asyncIterator 63
  73. 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 } } } } loige 64
  74. 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 } } } } loige 64
  75. HOT TIP 🔥 With async generators we can create objects

    that are both async iterators and async iterables! loige (We don't need to specify Symbol.asyncIterator explicitly!) 65
  76. 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 } } loige 66
  77. 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 } } loige 69
  78. 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 } } loige 69
  79. 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 } } loige 69
  80. Let's search for all the songs that contain the word

    "dark" in their title! 🧐 loige 71
  81. 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}`) } } } } loige 72
  82. For a more serious package that allows you to fetch

    data from Last.fm: loige npm install scrobbles 74
  83. Cover picture by on Thanks to Jacek Spera, , ,

    , for reviews and suggestions. Daniel Fontenele Unsplash @eoins @pelger @gbinside @ManuEomm - loige.link/enter-iterators loige.link/async-it-code for await (const _ of createAsyncCountdown(1_000_000)) { console.log("THANK YOU! 😍 ") } loige nodejsdp.link 75