Skip to Content
Course content

237: Building a Podcast Downloader CLI with Coroutines

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been wanting to build a quick CLI tool to archive some of my favorite podcast episodes before the hosts take them offline. Since we're dealing with network I/O—which is essentially just waiting around for a server to send us bytes—this is the perfect scenario to put our coroutine knowledge to work. I don't want to wait for episode one to finish before starting episode two.

The slow way: sequential downloads

I'll start with the most obvious approach. I have a list of URLs, and I'll just loop through them. I've written a helper function downloadFile that simulates a network request with a delay.

suspend fun downloadFile(url: String): String {
    println("Starting download: $url")
    delay(1000) // Simulating network lag
    return "Content from $url"
}

fun main() = runBlocking {
    val urls = listOf("ep1.mp3", "ep2.mp3", "ep3.mp3")
    val results = mutableListOf<String>()
    
    for (url in urls) {
        results.add(downloadFile(url))
    }
    println("Downloaded ${results.size} episodes.")
}

When I run this, it works, but it's painfully slow. It takes exactly three seconds because it's downloading them one by one. It's basically synchronous code wearing a suspend hat. I'm not actually utilizing the power of coroutines here; I'm just pausing the main thread repeatedly.

Where did the files go?

Okay, my first instinct is to just wrap the call in launch. I want these to fire off immediately without waiting for the previous one to finish. Let's try this:

fun main() = runBlocking {
    val urls = listOf("ep1.mp3", "ep2.mp3", "ep3.mp3")
    
    for (url in urls) {
        launch {
            downloadFile(url)
        }
    }
    println("Downloads started!")
}

Now it's fast. The "Downloads started!" message prints almost instantly. But there's a problem: if this were a real CLI tool and I wasn't using runBlocking at the top level, or if I used GlobalScope, the program would likely terminate before the downloads even finished. Even here, I have no way to know when they're actually done or if they succeeded. I've traded control for speed, and in a production tool, that's a recipe for corrupted files.

Getting them to run in parallel

I need a way to kick off all these downloads and then wait for all of them to return a result. This is where async comes in. Unlike launch, which is "fire and forget," async returns a Deferred—basically a promise that a value will eventually exist.

Let's adjust the logic. I'll map the URLs to a list of Deferred objects, and then await them all at once.

fun main() = runBlocking {
    val urls = listOf("ep1.mp3", "ep2.mp3", "ep3.mp3")
    
    val deferredDownloads = urls.map { url ->
        async {
            downloadFile(url)
        }
    }
    
    // awaitAll() is a handy extension function for collections of Deferred
    val results = deferredDownloads.awaitAll() 
    println("Successfully downloaded ${results.size} episodes.")
}

This is much better. The downloads happen concurrently, but the awaitAll() call ensures the program doesn't move past that point until every single episode is safely on disk. I'm seeing the "Starting download" logs appear almost simultaneously now.

Dealing with the occasional 404

In the real world, a podcast feed might have a dead link. Right now, if one async block throws an exception, it will propagate up and cancel all the other sibling coroutines in the scope. That's standard structured concurrency, but it's annoying here—I don't want one bad URL to stop me from getting the other ten episodes.

To fix this, I'll wrap the internal logic of the async block in a try-catch. I'll return null for failed downloads so I can filter them out later.

fun main() = runBlocking {
    val urls = listOf("ep1.mp3", "broken_link.mp3", "ep3.mp3")
    
    val deferredDownloads = urls.map { url ->
        async {
            try {
                downloadFile(url)
            } catch (e: Exception) {
                println("Failed to download $url: ${e.message}")
                null
            }
        }
    }
    
    val results = deferredDownloads.awaitAll().filterNotNull()
    println("Downloaded ${results.size} out of ${urls.size} episodes.")
}

Now the tool is resilient. It attempts everything, reports the failures, and still gives me the successful downloads. This pattern—map to async, followed by awaitAll and a filterNotNull—is my go-to for batch processing network requests in Kotlin.




📋 Practical Task

Build a Resilient Podcast Batch Downloader

Create a Kotlin program that simulates downloading a list of podcast episodes. Your program must meet the following requirements:

  • Define a suspend function fetchEpisode(url: String) that simulates a network delay using delay().
  • Randomly simulate a failure in fetchEpisode (e.g., using Random.nextBoolean() or checking if the URL contains the word "error") by throwing an IOException.
  • Use async and awaitAll() to download a list of at least 5 URLs concurrently.
  • Ensure that a failure in one download does not stop others from completing.
  • Print the final count of successfully downloaded episodes versus the total number attempted.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.