JohnCoates/Aerial

View on GitHub
Aerial/Source/Models/Sources/VideoList.swift

Summary

Maintainability
F
4 days
Test Coverage
//
//  VideoList.swift
//  Aerial
//
//  Created by Guillaume Louel on 08/07/2020.
//  Copyright © 2020 Guillaume Louel. All rights reserved.
//

import Foundation

typealias VideoListRefreshCallback = () -> Void
extension RangeReplaceableCollection {
    /// Returns a collection containing, in order, the first instances of
    /// elements of the sequence that compare equally for the keyPath.
    func unique<T: Hashable>(for keyPath: KeyPath<Element, T>) -> Self {
        var unique = Set<T>()
        return filter { unique.insert($0[keyPath: keyPath]).inserted }
    }
}

// swiftlint:disable:next type_body_length
class VideoList {
    enum FilterMode {
        case location, cache, time, scene, source, rotation, favorite, hidden
    }

    static let instance: VideoList = VideoList()
    var callbacks = [VideoListRefreshCallback]()

    var videos: [AerialVideo] = []

    // OLD Playlist management
    var playlistIsRestricted = false
    var playlistRestrictedTo = ""
    var playlistHasVerticalVideos = false
    var playlist = [AerialVideo]()
    var playlistForScreen: [String:[AerialVideo]] = [:]
    var lastPluckedFromPlaylist: AerialVideo?

    let cacheDownloaded = "Downloaded"
    let cacheOnline = "Online"
    init() {
        downloadManifestsIfNeeded()
    }

    func videoForFilename(_ name: String) -> AerialVideo? {
        for video in videos where video.url.lastPathComponent == name {
            return video
        }

        errorLog("vFF unknown video filename")
        return nil
    }

    // This is used to grab the correct path depending on whether a source is cacheable or not
    func localPathFor(video: AerialVideo) -> String {
        if video.source.isCachable {
            return VideoCache.cachePath(forVideo: video) ?? ""
        } else {
            return VideoCache.sourcePathFor(video)
        }
    }

    // MARK: - Helpers for the various filterings
    private func cacheSources() -> [String] {
        var cache: [String] = []

        if !videos.filter({ $0.isAvailableOffline && !PrefsVideos.hidden.contains($0.id) }).isEmpty {
            cache.append(cacheDownloaded)
        }
        if !videos.filter({ !$0.isAvailableOffline && !PrefsVideos.hidden.contains($0.id) }).isEmpty {
            cache.append(cacheOnline)
        }

        return cache
    }

    private func sourcesFor(_ mode: FilterMode) -> [String] {
        switch mode {
        case .location:
            return videos.filter { !PrefsVideos.hidden.contains($0.id) }.map { $0.name }.unique(for: \.self)
        case .time:
            return videos.filter { !PrefsVideos.hidden.contains($0.id) }.map { $0.timeOfDay.capitalizeFirstLetter() }.unique(for: \.self)
        case .scene:
            return videos.filter { !PrefsVideos.hidden.contains($0.id) }.map { $0.scene.rawValue.capitalizeFirstLetter() }.unique(for: \.self)
        case .source:
            return videos.filter { !PrefsVideos.hidden.contains($0.id) }.map { $0.source.name }.unique(for: \.self)
        case .cache:
            return cacheSources()
        case .rotation:
            return ["On Rotation"]
        case .favorite:
            return ["Favorites"]
        case .hidden:
            return ["Hidden"]
        }
    }

    private func filteredVideosFor(_ mode: FilterMode, section: Int) -> [AerialVideo] {
        switch mode {
        case .location:
            let filter = sourcesFor(mode)[section].lowercased()
            return videos
                .filter { $0.name.lowercased() == filter && !PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        case .time:
            let filter = sourcesFor(mode)[section].lowercased()
            return videos
                .filter { $0.timeOfDay.lowercased() == filter && !PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        case .scene:
            let filter = sourcesFor(mode)[section].lowercased()
            return videos
                .filter { $0.scene.rawValue.lowercased() == filter && !PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        case .source:
            let filter = sourcesFor(mode)[section].lowercased()
            return videos
                .filter { $0.source.name.lowercased() == filter && !PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        case .cache:
            // TODO FIX THIS IT CRASHES WHEN YOU FAV FROM ONLINE
            // if let cacheSources().
            if cacheSources()[section] == cacheDownloaded {
                return videos
                    .filter({ $0.isAvailableOffline && !PrefsVideos.hidden.contains($0.id) })
                    .sorted { $0.secondaryName < $1.secondaryName }
            } else {
                return videos
                    .filter({ !$0.isAvailableOffline && !PrefsVideos.hidden.contains($0.id) })
                    .sorted { $0.secondaryName < $1.secondaryName }
            }
        case .rotation:
            return currentRotation()    // Result is already sorted there
        case .favorite:
            return videos
                .filter { PrefsVideos.favorites.contains($0.id) && !PrefsVideos.hidden.contains($0.id)}
                .sorted { $0.secondaryName < $1.secondaryName }
        case .hidden:
            return videos
                .filter { PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        }

    }

    // swiftlint:disable:next cyclomatic_complexity
    private func filteredVideosFor(_ mode: FilterMode, filter: [String]) -> [AerialVideo] {
        // Our preference filters contains ALL sorts of filters (location, time) that are
        // saved for better user experience. So we need to filter the filters first !
        var filters: [String] = []

        for afilter in filter {
            switch mode {
            case .location:
                if afilter.starts(with: "location") {
                    filters.append(afilter.split(separator: ":")[1].lowercased())
                }
            case .cache:
                filters.append(afilter.lowercased())
            case .time:
                if afilter.starts(with: "time") {
                    filters.append(afilter.split(separator: ":")[1].lowercased())
                }
            case .scene:
                if afilter.starts(with: "scene") {
                    filters.append(afilter.split(separator: ":")[1].lowercased())
                }
            case .source:
                if afilter.starts(with: "source") {
                    filters.append(afilter.split(separator: ":")[1].lowercased())
                }
            case .rotation:
                filters.append(afilter.lowercased())
            case .favorite:
                filters.append(afilter.lowercased())
            case .hidden:
                filters.append(afilter.lowercased())
            }
        }

        switch mode {
        case .location:
            let vids = videos
                .filter { filters.contains($0.name.lowercased()) && !PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
            return vids
        case .time:
            return videos
                .filter { filters.contains($0.timeOfDay.lowercased()) && !PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        case .scene:
            return videos
                .filter { filters.contains($0.scene.rawValue.lowercased()) && !PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        case .source:
            return videos
                .filter { filters.contains($0.source.name.lowercased()) && !PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        case .favorite:
            return videos
                .filter { PrefsVideos.favorites.contains($0.id) && !PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        case .hidden:
            return videos
                .filter { PrefsVideos.hidden.contains($0.id) }
                .sorted { $0.secondaryName < $1.secondaryName }
        default:
            return videos
                .filter({ $0.isAvailableOffline })
                .sorted { $0.secondaryName < $1.secondaryName }
        }
    }

    // MARK: - Public getters to filter the list
    func getSources(mode: FilterMode) -> [String] {
        return sourcesFor(mode)
    }

    func getSourcesCount(mode: FilterMode) -> Int {
        return sourcesFor(mode).count
    }

    func getSourceName(_ section: Int, mode: FilterMode) -> String {
        return sourcesFor(mode)[section]
    }

    func getVideosCountForSource(_ section: Int, mode: FilterMode) -> Int {
        return filteredVideosFor(mode, section: section).count
    }

    func getVideoForSource(_ section: Int, item: Int, mode: FilterMode) -> AerialVideo {
        return filteredVideosFor(mode, section: section)[item]
    }

    func getVideosForSource(_ section: Int, mode: FilterMode) -> [AerialVideo] {
        return filteredVideosFor(mode, section: section)
    }

    // MARK: - Public getter for a video list from paths
    func getVideosForPath(_ path: String) -> [AerialVideo] {
        if let mode = VideoList.instance.modeFromPath(path) {
            let index = Int(path.split(separator: ":")[1])!
            return VideoList.instance.getVideosForSource(index, mode: mode)
        } else {
            // all
            return VideoList.instance.videos.filter({ !PrefsVideos.hidden.contains($0.id) }).sorted { $0.secondaryName < $1.secondaryName }
        }
    }

    func modeFromPath(_ path: String) -> FilterMode? {
        if path.starts(with: "location") {
            return .location
        } else if path.starts(with: "cache") {
            return .cache
        } else if path.starts(with: "time") {
            return .time
        } else if path.starts(with: "scene") {
            return .scene
        } else if path.starts(with: "rotation") {
            return .rotation
        } else if path.starts(with: "source") {
            return .source
        } else if path.starts(with: "favorites") {
            return .favorite
        } else if path.starts(with: "hidden") {
            return .hidden
        } else {
            return nil
        }
    }

    // MARK: - Callbacks
    func addCallback(_ callback:@escaping VideoListRefreshCallback) {
        callbacks.append(callback)

        // We may need to insta callback if we were already inited
        if !videos.isEmpty {
            callback()
        }
    }

    // This is how we force a source refresh, it will trigger various callbacks when done
    // (e.g. to refresh video list in the ui)
    func reloadSources() {
        videos = []
        downloadManifestsIfNeeded()
    }

    func downloadSource(source: Source) {
        let downloadManager = DownloadManager()

        let completion = BlockOperation {
            self.refreshVideoList()
            if !PrefsCache.enableManagement {
                Aerial.helper.showInfoAlert(title: "Automatic downloads are disabled", text: "In order to watch the new videos, you will need to manually download them (for example by pressing the down arrow button on the right).")
            }
        }

        for src in SourceList.list where source.name == src.name {
            debugLog("Marking \(source.name) for redownload")
            // Then queue the download
            let operation = downloadManager.queueDownload(URL(string: source.manifestUrl)!, folder: source.name)
            completion.addDependency(operation)

            OperationQueue.main.addOperation(completion)
        }
    }

    private func downloadManifestsIfNeeded() {
        let downloadManager = DownloadManager()

        var sourceQueue: [Source] = []

        let completion = BlockOperation {
            self.refreshVideoList()
        }

        // Let's check our sources first
        for source in SourceList.list {
            // But only the enabled ones
            if source.isEnabled() {
                // We may need to download it
                if !source.isCached() {
                    debugLog("\(source.name) is not cached, downloading...")
                    sourceQueue.append(source)
                } else if PrefsVideos.shouldCheckForNewVideos() && Cache.canNetwork() {
                    debugLog("\(source.name) looking for updated manifest...")
                    sourceQueue.append(source)
                } else {
                    debugLog("\(source.name) is enabled, cached and up to date")
                }
            }
        }

        for source in SourceList.list {
            if source.type == .local {
                debugLog("\(source.name) updating local source")
                SourceList.updateLocalSource(source: source, reload: false)
            }
        }

        if !sourceQueue.isEmpty {
            // Now queue and download
            for source in sourceQueue {
                // Then queue the download
                let operation = downloadManager.queueDownload(URL(string: source.manifestUrl)!, folder: source.name)
                completion.addDependency(operation)

                // Mark that we updated our sources
                PrefsVideos.saveLastVideoCheck()
            }

            OperationQueue.main.addOperation(completion)
        } else {
            DispatchQueue.main.async {
                self.refreshVideoList()
            }
        }
    }

    // This is called when all our files are downloaded
    private func refreshVideoList() {
        debugLog("Refreshing video list")

        videos = []

        for source in SourceList.list {
            if source.isEnabled() {
                // We may need to download it
                if source.isCached() {
                    let vids = source.getVideos()
                    videos.append(contentsOf: vids)
                    debugLog("source : \(source.name) contains \(vids.count) new videos (total \(videos.count))")
                }
            }
        }

        videos = videos.sorted { $0.name < $1.name }

        // Let everyone who wants to know that our list is updated
        for callback in callbacks {
            callback()
        }
    }

    // MARK: - New rotation management
    func currentRotation() -> [AerialVideo] {
        var mode: FilterMode
        switch PrefsVideos.newShouldPlay {
        case .location:
            mode = .location
        case .time:
            mode = .time
        case .scene:
            mode = .scene
        case .source:
            mode = .source
        default:
            mode = .cache
        }

        switch PrefsVideos.newShouldPlay {
/*        case .everything:
            return videos
                .filter({ !PrefsVideos.hidden.contains($0.id) })
                .sorted { $0.secondaryName < $1.secondaryName }*/
        case .favorites:
            return videos
                .filter({ PrefsVideos.favorites.contains($0.id) && !PrefsVideos.hidden.contains($0.id) })
                .sorted { $0.secondaryName < $1.secondaryName }
        default:
            print(PrefsVideos.newShouldPlayString)
            return filteredVideosFor(mode, filter: PrefsVideos.newShouldPlayString)
        }
    }
    
    func everythingRotation() -> [AerialVideo] {
        return videos
            .filter({ !PrefsVideos.hidden.contains($0.id) })
            .sorted { $0.secondaryName < $1.secondaryName }
    }

    // MARK: - Playlist management
    func generatePlaylist(isRestricted: Bool, restrictedTo: String, isVertical: Bool) {
        debugLog("generate playlist (isVertical: \(isVertical)")
        // Start fresh
        playlist = [AerialVideo]()
        playlistIsRestricted = isRestricted
        playlistRestrictedTo = restrictedTo
        playlistHasVerticalVideos = false

        var shuffled = currentRotation().shuffled()

        // If we have nothing, just get everything
        if shuffled.count == 0 {
            shuffled = everythingRotation().shuffled()
        }
        
        let cachedShuffled = shuffled.filter({ $0.isAvailableOffline })

        
        debugLog("Playlist raw count: \(shuffled.count) raw cached count \(cachedShuffled.count) isRestricted: \(isRestricted) restrictedTo: \(restrictedTo)")

        if PrefsDisplays.viewingMode == .independent && PrefsAdvanced.favorOrientation {
            // We check cached videos only as those are the only ones for which we know the orientation
            for video in cachedShuffled {
                // swiftlint:disable:next for_where
                if video.isVertical {
                    playlistHasVerticalVideos = true
                    debugLog(">>> Playlist contains vertical videos (favoring ON)")
                }
            }
        }

        for video in shuffled {
            /*
            // Do we restrict videos by screen orientation ?
            if restrictOrientation {
                print(video.url)
                print(video.isVertical)
                if !video.isVertical && isVertical {
                    // Block landscape videos on vertical screens
                    continue
                } else if video.isVertical && !isVertical {
                    // Block portrait videos on horizontal screens
                    continue
                }
            }*/

            // Do we restrict video types by day/night ?
            if isRestricted {
                if video.timeOfDay != restrictedTo {
                    continue
                }
            }

            if !video.isAvailableOffline {
                continue
            }

            // All good ? Add to playlist
            playlist.append(video)
        }

        debugLog("Final count : \(playlist.count)")
        // On regenerating a new playlist, we try to avoid repeating the last thing we played!
        while playlist.count > 1 && lastPluckedFromPlaylist == playlist.first {
            playlist.shuffle()
        }
    }

    func randomVideo(excluding: [AerialVideo], isVertical: Bool) -> (AerialVideo?, Bool) {
        var shouldLoop = false
        let timeManagement = TimeManagement.sharedInstance

        let (shouldRestrictByDayNight, restrictTo) = timeManagement.shouldRestrictPlaybackToDayNightVideo()

        // Do we still have a video in the correct format in the playlist?
        var needOrientedVideo = false
        if playlistHasVerticalVideos && !playlist.isEmpty {
            needOrientedVideo = true
            for video in playlist {
                if isVertical && video.isVertical {
                    needOrientedVideo = false
                } else if !isVertical && !video.isVertical {
                    needOrientedVideo = false
                }
            }
        }

        debugLog("remaining in playlist : \(playlist.count) needOrientedVideo : \(needOrientedVideo)")

        // We may need to regenerate a playlist!
        if playlist.isEmpty || restrictTo != playlistRestrictedTo || shouldRestrictByDayNight != playlistIsRestricted || needOrientedVideo {
            generatePlaylist(isRestricted: shouldRestrictByDayNight, restrictedTo: restrictTo, isVertical: isVertical)
            if playlist.count == 1 {
                debugLog("playlist only has one element, looping!")
                shouldLoop = true
            }
        }

        // If not pluck one from current playlist and return that
        if !playlist.isEmpty {
            if playlistHasVerticalVideos {
                lastPluckedFromPlaylist = pluckOrientedVideo(isVertical: isVertical)
            } else {
                lastPluckedFromPlaylist = playlist.removeFirst()
            }

            return (lastPluckedFromPlaylist, shouldLoop)
        } else {
            // If we don't have any playlist, something's got awfully wrong so deal with that!
            return (findBestEffortVideo(), shouldLoop)
        }
    }

    func pluckOrientedVideo(isVertical: Bool) -> AerialVideo? {
        // Grab first one corresponding to orientation
        lastPluckedFromPlaylist = playlist.first(where: { $0.isVertical == isVertical })!
        debugLog("lastplucked")

        // And actually remove it
        debugLog("pre pluck \(playlist.count)")
        playlist = playlist.filter { $0 != lastPluckedFromPlaylist }
        debugLog("post pluck \(playlist.count)")

        return lastPluckedFromPlaylist
    }

    // Find a backup plan when conditions are not met
    func findBestEffortVideo() -> AerialVideo? {
        // So this is embarassing. This can happen if :
        // - No video checked
        // - No video for current conditions (only day video checked, and looking for night)
        // - We don't want to stream but don't have any video
        // - We may not have the manifests
        // At this point we're doing a best effort :
        // - Did we play something previously ? If so play that back (will loop)
        // - return a random one from the manifest that is cached
        // - return a random video that is not cached (slight betrayal of the Never stream videos)

        warnLog("Empty playlist, not good !")

        if lastPluckedFromPlaylist != nil {
            warnLog("Repeating last played video, after condition change not met !")
            return lastPluckedFromPlaylist!
        } else {
            // Start with a shuffled list
            let shuffled = videos.shuffled()

            if shuffled.isEmpty {
                // This is super bad, no manifest at all
                errorLog("No manifest, nothing to play !")
                return nil
            }

            for video in shuffled {
                // If we find anything cached and in rotation, we send that back
                if video.isAvailableOffline && currentRotation().contains(video) {
                    warnLog("returning random cached in rotation video after condition change not met !")
                    return video
                }
            }

            // We try to return something that's at least in the rotation, if there is one
            if !currentRotation().isEmpty {
                warnLog("returning random non cached BUT in rotation video after condition change not met !")
                return currentRotation().shuffled().first
            }

            // Really nothing ? I can't even !
            warnLog("returning truly random video after condition change not met !")
            return shuffled.first!
        }
    }

}