Leave a rating/review
Notes: 11. Download Music
URLSession contains many different tasks to preform a variety of functions.
The data task allows you to download content from a web service like the download task, but the data is stored in memory versus being saved to disk. These are really meant for short lived tasks.
Now, you’ll explore the download task. Download tasks store the response data in a file, so a download task with URL, or URL request and completion handler gets back a URL instead of a data object.
This is the location of a temporary file, so the completion handler must either read and process the data in it.
Or copy the file to a permanent location in your app’s container directory. You’ll see the process of copying files, but if you haven’t done any file manipulation on iOS then check out our Saving Data course.
URLSession supports a variety of delegate methods. URLSessionDelegate responds to general events. For instance, if your session has been invalidated or you received an authentication challenge.
URLSessionTaskDelegate handles events related to tasks. For instance, if a task is waiting for connectivity or if a task encountered a redirect. There are also delegates specific for a task.
URLSessionDataDelegate handles events related to both the data task and the upload task.
For managing downloads, you can make use of URLSessionDownloadDelegate. Use this to handle a variety of situations related to downloads such as showing progress, and pausing and resuming downloads.
Time to write some code and use some of this functionality. Start by opening the Starter project for this episode.
This time, you’ll see that it’s a full Xcode project containing an iOS app that you’ll extended throughout the course to add more functionality. Build and run the app which, for now, doesn’t really do anything. Don’t worry, you’ll get things working in just a bit.
For now, feel free to take a look at the files that make up the app. You won’t go over it in detail right now, but you’ll open up files, or create new files as we go along.
You’re going to download a song from the iTunes preview API. Create a new Model file called SongDownloader.swift. Replace the Foundation import for a SwiftUI one:
import SwiftUI
Next, add the following code for the SongDownloader class:
class SongDownloader: ObservableObject {
// MARK: Properties
@Published var downloadLocation: URL?
private let session: URLSession
private let sessionConfiguration: URLSessionConfiguration
// MARK: Initialization
init() {
}
// MARK: Functions
}
Running through this code from top to bottom: 1. You create a class called SongDownloader that conforms to ObservableObject, so you can have it work well with your SwiftUI views. 2. You create a Published URL property for the song’s download location. 3. Next up, you create some private properties for the URLSession and URLSessionConfiguration you’ll use. 4. Finally, you add an empty initializer for now.
Right now the code gives you an error about not having initialized all stored properties, so let’s take care of that. Add this code inside the init method:
self.sessionConfiguration = URLSessionConfiguration.default
self.session = URLSession(configuration: sessionConfiguration)
This sets your property to a default URLSessionConfiguration, and creates a new URLSession with your configuration that’s stored in a property.
Time to move on to actually downloading the song. Start by adding the following method declaration:
func downloadSong(at url: URL) async {
}
This is a method that takes a parameter of type URL, which will contain the location from which to download the song.
This method does not have a return value, but it’s marked async as it’s an asynchronous method you can await. Next, add this code inside your method:
guard let (downloadURL, response) = try? await session.download(from: url) else {
print("Error downloading song.")
return
}
Similar to code from previous episodes, this code uses your URLSession to perform an asynchronous download from the specified URL. Because this call can throw an error and is asynchronous, you use try and await.
Should the call return a valid tuple, containing the URL where the downloaded song is at as well as the response, then you proceed, otherwise a print statement will tell you that an error occurred and you return from the method without doing anything else.
Similar to what you also did previously, the response should be checked to ensure it’s valid and that things went well. Add this code next:
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
print("Invalid response code.")
return
}
This tries to cast the responses as an HTTPURLResponse and checks for its status code to be 200. If it isn’t, then another print statement is added and you return from the method.
You’ll work with FileManager to copy the downloaded file from its location into a more permanent location that you prefer.
Start by getting the path to your app’s Documents directory:
let fileManager = FileManager.default
guard let documentsPath = fileManager.urls(for: .documentDirectory,
in: .userDomainMask).first
else {
print("Song download failed.")
return
}
And then construct the final URL where you want to store the song:
let lastPathComponent = url.lastPathComponent
let destinationURL = documentsPath.appendingPathComponent(lastPathComponent)
With everything ready to perform the file copy, add the following code:
do {
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
try fileManager.copyItem(at: downloadURL, to: destinationURL)
} catch {
print("Failed to store the song.")
}
Everything is wrapped inside a do-catch statement since some of the methods on FileManager can throw errors. If any occur, you print a message to the console.
In your main do block, however, you first check whether the file you are trying to copy doesn’t already exist in the desired location. Should there already be such a file then you remove it first before proceeding with the copy.
To actually perform the copy, you call copyItem on FileManager and provide it the source and destination URL.
Finally, and to wrap up work on this method, you need to update your downloadLocation property with the new URL where the song file is at.
Of importance, and because this is a Published property that can potentially cause the UI to update, you want to make sure you set the property on the main thread. For that, add the following code:
await MainActor.run {
downloadLocation = destinationURL
}
This will asynchronously execute your code on the main actor. And that wraps up work on your asynchronous song downloading method. Time to put it to use.
Open SongDetailView.swift. Add the following property towards the top of the struct:
@ObservedObject private var downloader: SongDownloader = SongDownloader()
This creates an instance of your SongDownloader class, and it uses the ObservedObject attribute so your UI can update whenever its observed state changes.
You something to happen when the download button is tapped instead of the existing logic that just prints out a message to the console.
For that, how about putting the download tapped code inside a helper function? Inside the SongDetailView struct, add the following:
private func downloadTapped() async {
if downloader.downloadLocation == nil {
guard let previewURL = musicItem.previewURL else {
return
}
await downloader.downloadSong(at: previewURL)
} else {
playMusic = true
}
}
Once again, from top to bottom, youe have a private, async function. It’s async because SongDownloader‘s downloadSong method is also async, so you wanna keep leveraging the concurrent context it’ll execute in when this function is called.
Next, you check whether the song downloader’s download location is nil or not. If it isn’t then the song is already downloaded and you can play it.
Otherwise, you proceed to call downloadSong and await for it to finish downloading the song. To make use of this new function, replace the button in your view’s body with:
Button(action: {
Task {
await downloadTapped()
}
}) {
Text(downloader.downloadLocation == nil ? "Download" : "Listen")
}
Notice how the button’s label now intelligently shows either Download or Listen depending on whether the song has already been downloaded or not.
As for the button action itself, you wrap the call to downloadTapped inside a task, since it’s an asynchronous call and await for it to complete.
The last piece of the puzzle is what to do when the song is already downloaded and the user taps on Listen. Add this code after the padding modifier:
.sheet(isPresented: $playMusic) {
AudioPlayer(songUrl: downloader.downloadLocation!)
}
Build and run the app, and tap the Download button.
Fantastic! The song downloaded and the button’s title now reads Listen. Now tap the Listen button. Yaaaay! Great work.
Your code is doing a lot of things. It’s asynchronously downloading an actual song preview from the iTunes API, it gets stored and copied to your app’s Documents directoy and you can play it back all from within your app. A lot of very cool work in just a few lines of code.
One final bit of polish you could do is perhaps give the user feedback that something is happening.
Right now my internet connection is fast and working well, but if I, or our users, were to be on a slower connection then it might seem like the app froze or is doing nothing. Add this property inside the view:
@MainActor @State private var isDownloading: Bool = false
This State property will help users know when a download is in progress. Note how it’s also annotated using the MainActor attribute, so any work on it is guaranteed to be done in the main thread.
Since downloadTapped is where the actual download is happening, some updates are needed there as well:
private func downloadTapped() async {
if downloader.downloadLocation == nil {
isDownloading = true // THIS
defer { // THIS
isDownloading = false // THIS
} // THIS
guard let previewURL = musicItem.previewURL else {
return
}
await downloader.downloadSong(at: previewURL)
} else {
playMusic = true
}
}
If the song isn’t downloaded, but before the download begins, you set isDownloading to true, and you’ll update your UI to leverage this property in a minute.
Right below it, the defer statement is used to execute some code after downloadSong has finished and before the method returns.
This could have been added after the line that awaits the song download, and also before the guard statement’s return.
To avoid duplicate code and also potentially forgetting about this and introducing a bug, the defer statement is used.
Finally and to wrap all of this up, it’s time to update the view to use your new property. Start by changing the button’s label:
if isDownloading {
Text("Downloading...")
} else {
Text(downloader.downloadLocation == nil ? "Download" : "Listen")
}
This will check whether a download is in progress and, if so, shows Downloading to your users. Next, add the following modifier to the button:
.disabled(isDownloading)
This way, users can’t tap the download button many times while it’s already downloading the song. While not strictly necessary, it can help prevent edge cases and potential bugs. And finally add a ProgressView to indicate something is indeed happening:
if isDownloading {
ProgressView()
}
Build and run the app one last time.
Tap Download, and check out the results. Yaaaay once more! Now not only are you downloading the song, but your user interface reflects that and gives visual feedback to your users about it. Great work, but also great polish and attention to detail.
Phew, that was a lot of work, but you’ve seen a different asynchronous transfer that URLSession support, and you’ve actually integrated it into a real app. No more playgrounds or sample requests.
In the next episode, you’ll see how to better handle errors that can happen in SongDownloaders downloadSong method.
While printing to the console is a good indicator for you that something went wrong, your users currently have no idea and might be left frustrated when things don’t work.
So what are you waiting for? I’ll see ya in the next episode! :)