Episode #414

Importing Episodes from a Feed

Series: Making a Podcast App From Scratch

22 minutes
Published on October 22, 2019

This video is only available to subscribers. Get access to this video and 572 others.

We finish off our operation to import all the episodes given a podcast id and save into the core data store. We also implement a FeedImporter class that listens for new subscriptions in order to kick off the import when a user subscribes.

Creating Context for Import Operation

As we complete the refactoring of the SubscriptionStore, we'll now create an instance of the store with a background queue.

class ImportEpisodesOperation : BaseOperation {

    private let podcastId: String
    private let feedLoader = PodcastFeedLoader()
    private var context: NSManagedObjectContext!
    private var subscriptionStore: SubscriptionStore!

    override func execute() {
        context = PersistenceManager.shared.newBackgroundContext()
        subscriptionStore = SubscriptionStore(context: context)
    }
}

Loading the Podcast

We'll start by loading the podcast ID in the initializer. As this work is entirely done in the background, we'll make sure that we log the details for every step in the console so it is clear what is happening.

init(podcastId: String) {
    self.podcastId = podcastId
}

override func execute() {
    context = PersistenceManager.shared.newBackgroundContext()
    subscriptionStore = SubscriptionStore(context: context)

    // load podcast
    print("ImportEpisodes -> Loading podcast: \(podcastId)")
    guard let podcastEntity = loadPodcast() else {
        finish()
        return
    }
}

We'll try loading the podcast using podcast id. In case of failure, we'll log the error on the console.

private func loadPodcast() -> PodcastEntity? {
    do {
        guard let podcast = try subscriptionStore.findPodcast(with: podcastId) else {
            print("Couldn't find podcast with id: \(podcastId)")
            return nil
        }
        return podcast
    } catch {
        print("Error fetching podcast: \(error.localizedDescription)")
        return nil
    }
}

In SubscriptionStore we'll add logic to fetch the podcast using podcast id.

func findPodcast(with podcastId: String) throws -> PodcastEntity? {
    let fetch: NSFetchRequest<PodcastEntity> = PodcastEntity.fetchRequest()
    fetch.fetchLimit = 1
    fetch.predicate = NSPredicate(format: "id == %@", podcastId)
    return try context.fetch(fetch).first
}

Importing a Feed

To reuse the lookupInfo created for the podcast we'll add this logic in PodcastEntity.

var lookupInfo: PodcastLookupInfo? {
    guard let id = id else { return nil }
    guard let feedURL = feedURLString.flatMap(URL.init) else { return nil }
    return PodcastLookupInfo(id: id, feedURL: feedURL)
}

Now we'll update the MyPodcastsViewController to fetch the podcast.

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let podcast = podcasts[indexPath.row]
    if let lookup = podcast.lookupInfo {
        showPodcast(with: lookup)
    }
}

We'll also import the feed using lookup info in ImportEpisodesOperation.

// import the feed
print("ImportEposodes -> Fetching the feed for \(podcastEntity.title ?? "<?>") - \(podcastEntity.feedURLString ?? "<?>")")
guard let lookup = podcastEntity.lookupInfo else {
    print("Couldn't build lookup info")
    finish()
    return
}

feedLoader.fetch(lookup: lookup) { result in
    switch result {
    case .failure(let error):
        print("Error loading feed: \(error.localizedDescription)")
        self.finish()

    case .success(let podcast):
        self.importEpisodes(podcast.episodes, podcast: podcastEntity) 
        // saving the episodes
        self.finish()
    }
}

private func importEpisodes(_ episodes: [Episode], podcast: PodcastEntity) {

}

Importing the Episodes

Once we fetch the episodes, we'll associate the podcast episodes and podcast entity. Here we also make sure not to insert duplicates for an existing episode, instead updating the object that we have.

Instead of looping over all the episodes we'll create a lookup for existing episodes by grouping it with episode identifier and then create an episode entity for a new subscribed episode.

private func importEpisodes(_ episodes: [Episode], podcast: PodcastEntity) {
    var existingEpisodes = [String : EpisodeEntity]()
    podcast.episodes?
        .map { $0 as! EpisodeEntity }
        .forEach {
            existingEpisodes[$0.identifier] = $0
        }

    for episode in episodes {
        guard let episodeId = episode.identifier else {
            print("Skipping episode \(episode.title ?? "<?>") because it has no identifier")
            continue
        }

        guard let enclosureURL = episode.enclosureURL else {
            print("Skipping episode \(episode.title ?? "<?>") because it has no enclosure")
            continue
        }

        let episodeEntity = existingEpisodes[episodeId] ?? EpisodeEntity(context: context)
        episodeEntity.identifier = episodeId
        episodeEntity.podcast = podcast
        episodeEntity.title = episode.title ?? "Untitled"
        episodeEntity.publicationDate = episode.publicationDate ?? Date()
        episodeEntity.duration = episode.duration ?? 0
        episodeEntity.episodeDescription = episode.description ?? ""
        episodeEntity.enclosureURL = enclosureURL

        print("Importing \(episodeEntity.title)...")
    }
}

Saving the Episode of a Feed

Now we have updated the record in the memory but haven't persisted the record in the Core Data store.

feedLoader.fetch(lookup: lookup) { result in
    switch result {
    case .failure(let error):
        print("Error loading feed: \(error.localizedDescription)")
        self.finish()

    case .success(let podcast):
        self.importEpisodes(podcast.episodes, podcast: podcastEntity) 
        self.saveChanges()
        self.finish()
    }
}

private func saveChanges() {
    context.performAndWait {
        do {
            try context.save()
        } catch {
            print("Error saving changes: \(error.localizedDescription)")
        }
    }
}

Implementing the Feed Importer

To save the subscribed podcast episode from a feed, we'll add a listener for the subscriptions and automatically observer for the notifications.

class FeedImporter {
    static let shared = FeedImporter()

    private var notificationObserver: NSObjectProtocol?
    private var importQueue: OperationQueue = {
        let q = OperationQueue()
        q.maxConcurrentOperationCount = 2
        q.qualityOfService = .userInitiated
        return q
    }()

    func startListening() {
        notificationObserver = NotificationCenter.default.addObserver(SubscriptionsChanged.self, sender: nil, queue: nil) { notification in
            notification.subscribedIds.forEach(self.onSubscribe)
            notification.unsubscribedIds.forEach(self.onUnsubscribe)
        }
    }

    private func onSubscribe(podcastId: String) {
        let operation = ImportEpisodesOperation(podcastId: podcastId)
        importQueue.addOperation(operation)
    }

    private func onUnsubscribe(podcastId: String) {

    }
}

Once the core data is loaded we'll start listening to the FeedImporter.

PersistenceManager.shared.initializeModel(then: {
    FeedImporter.shared.startListening()

    let storyboard = UIStoryboard(name: "Main", bundle: nil)
    self.window?.rootViewController = storyboard.instantiateInitialViewController()            
})

Once we launch the application, we'll subscribe to a podcast and take a look at the path for the core data store displayed in the console. We'll see the episode information saved in the core data database for the recent subscribed podcast.

This episode uses Xcode 11.0, Swift 5.1.