JohnCoates/Aerial

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

Summary

Maintainability
F
5 days
Test Coverage
//
//  Source.swift
//  Aerial
//
//  Created by Guillaume Louel on 01/07/2020.
//  Copyright © 2020 Guillaume Louel. All rights reserved.
//

import Foundation

// 10 has a different format
// 11 is similar to 12+, but does not include pointsOfInterests
// 12/13 share a same format, and we use that format for local videos too
enum SourceType: Int, Codable {
    case local, tvOS10, tvOS11, tvOS12, macOS
}

enum SourceScene: String, Codable {
    case nature = "Nature", city = "City", space = "Space", sea = "Sea", beach = "Beach", countryside = "Countryside"
}

// swiftlint:disable:next type_body_length
struct Source: Codable {
    var name: String
    var description: String
    var manifestUrl: String
    var type: SourceType
    var scenes: [SourceScene]
    var isCachable: Bool
    var license: String
    var more: String
    
    func isEnabled() -> Bool {
        if PrefsVideos.enabledSources.keys.contains(name) {
            return PrefsVideos.enabledSources[name]!
        }

        // Unknown sources are enabled by default
        return true
    }

    func diskUsage() -> Double {
        let path = Cache.supportPath.appending("/" + name)

        return Cache.getDirectorySize(directory: path)
    }

    func wipeFromDisk() {
        let path = Cache.supportPath.appending("/" + name)

        if FileManager.default.fileExists(atPath: path) {
            try? FileManager.default.removeItem(atPath: path)
        }
    }

    func setEnabled(_ enabled: Bool) {
        PrefsVideos.enabledSources[name] = enabled
        VideoList.instance.reloadSources()
    }

    // Is the source already cached or not ?
    func isCached() -> Bool {
        let fileManager = FileManager.default
        return fileManager.fileExists(atPath: Cache.supportPath.appending("/" + name + "/entries.json"))
    }

    func lastUpdated() -> String {
        if isCached() {
            var date: Date?
            if !isCachable && type == .local {
                date = (try? FileManager.default.attributesOfItem(atPath:
                Cache.supportPath.appending("/" + name + "/entries.json")))?[.modificationDate] as? Date
            } else {
                date = (try? FileManager.default.attributesOfItem(atPath:
                Cache.supportPath.appending("/" + name + "/entries.json")))?[.creationDate] as? Date
            }

            if date != nil {
                let dateFormatter = DateFormatter()
                dateFormatter.dateFormat = "yyyy-MM-dd"
                return dateFormatter.string(from: date!)
            } else {
                return ""
            }
        }
        return ""
    }

    // Read local entries.json and return the video assets as an array
    // This is used to update in place the entries.json at startup when updating local sources
    func getUnprocessedAssets() -> [VideoAsset] {
        if isCached() {
            do {
                let cacheFileUrl = URL(fileURLWithPath: Cache.supportPath.appending("/" + name + "/entries.json"))
                let jsondata = try Data(contentsOf: cacheFileUrl)

                if let videoManifest = try? newJSONDecoder().decode(VideoManifest.self, from: jsondata) {
                    return videoManifest.assets
                }

                errorLog("### Could not parse manifest data")
                return []
            } catch {
                errorLog("\(name) could not be opened")
                return []
            }
        } else {
            debugLog("\(name) is not cached")
            return []
        }
    }
    
    func getUnprocessedVideos() -> [AerialVideo] {
        if isCached() {
            do {
                let cacheFileUrl = URL(fileURLWithPath: Cache.supportPath.appending("/" + name + "/entries.json"))
                let jsondata = try Data(contentsOf: cacheFileUrl)

                return readVideoManifest(jsondata)
            } catch {
                errorLog("\(name) could not be opened")
                return []
            }
        } else {
            debugLog("\(name) is not cached")
            return []
        }
    }

    func getVideos() -> [AerialVideo] {
        if isCached() {
            do {
                let cacheFileUrl = URL(fileURLWithPath: Cache.supportPath.appending("/" + name + "/entries.json"))
                let jsondata = try Data(contentsOf: cacheFileUrl)

                if name == "tvOS 10" {
                    return parseOldVideoManifest(jsondata)
                } else if name.starts(with: "tvOS 13") {
                    return parseVideoManifest(jsondata) + getMissingVideos()  // Oh, Victoria Harbour 2...
                } else if name.starts(with: "macOS") {
                    return parseMacManifest(jsondata)
                } else {
                    return parseVideoManifest(jsondata)
                }
            } catch {
                errorLog("\(name) could not be opened")
                return []
            }
        } else {
            debugLog("\(name) is not cached")
            return []
        }
    }

    func localizePath(_ path: String?) -> String {
        if let tpath = path {
            if manifestUrl.starts(with: "file://") {
                return manifestUrl + tpath
            }

            return tpath
        } else {
            return ""
        }
    }

    // The things we do for one single missing video (for now) ;)
    func getMissingVideos() -> [AerialVideo] {
        // We also need to add the missing videos
        let bundlePath = Bundle(for: PanelWindowController.self).path(forResource: "missingvideos", ofType: "json")!
        do {
            let data = try Data(contentsOf: URL(fileURLWithPath: bundlePath), options: .mappedIfSafe)
            return parseVideoManifest(data)
        } catch {
            errorLog("missingvideos.json was not found in the bundle")
        }

        return []
    }

    // MARK: - JSON processing
    func readOldJSONFromData(_ data: Data) -> [AerialVideo] {
        var processedVideos: [AerialVideo] = []

        do {
            let poiStringProvider = PoiStringProvider.sharedInstance

            let options = JSONSerialization.ReadingOptions.allowFragments
            let batches = try JSONSerialization.jsonObject(with: data,
                                                           options: options) as! [NSDictionary]

            for batch: NSDictionary in batches {
                let assets = batch["assets"] as! [NSDictionary]
                // rawCount = assets.count

                for item in assets {
                    let url = item["url"] as! String
                    let name = item["accessibilityLabel"] as! String
                    let timeOfDay = item["timeOfDay"] as! String
                    let id = item["id"] as! String
                    let type = item["type"] as! String

                    if type != "video" {
                        continue
                    }

                    // We may have a secondary name
                    var secondaryName = ""
                    if let mergename = poiStringProvider.getCommunityName(id: id) {
                        secondaryName = mergename
                    }

                    // We may have POIs to merge
                    /*var poi: [String: String]?
                    if let mergeId = SourceInfo.mergePOI[id] {
                        let poiStringProvider = PoiStringProvider.sharedInstance
                        poi = poiStringProvider.fetchExtraPoiForId(id: mergeId)
                    }*/

                    let communityPoi = poiStringProvider.getCommunityPoi(id: id)

                    // We may have dupes...
                    let (isDupe, foundDupe) = SourceInfo.findDuplicate(id: id, url1080pH264: url)
                    if isDupe {
                        if foundDupe != nil {
                            // foundDupe!.sources.append(manifest)

                            if foundDupe?.urls[.v1080pH264] == "" {
                                foundDupe?.urls[.v1080pH264] = url
                            }
                        }
                    } else {
                        var url1080pHEVC = ""
                        var url1080pHDR = ""
                        var url4KHEVC = ""
                        var url4KHDR = ""

                        // Check if we have some HEVC urls to merge
                        if let val = SourceInfo.mergeInfo[id] {
                            url1080pHEVC = val["url-1080-SDR"]!
                            url1080pHDR = val["url-1080-HDR"]!
                            url4KHEVC = val["url-4K-SDR"]!
                            url4KHDR = val["url-4K-HDR"]!
                        }

                        let urls: [VideoFormat: String] = [.v1080pH264: url,
                                                           .v1080pHEVC: url1080pHEVC,
                                                           .v1080pHDR: url1080pHDR,
                                                           .v4KHEVC: url4KHEVC,
                                                           .v4KHDR: url4KHDR ]

                        // Now we can finally add...
                        let video = AerialVideo(id: id,             // Must have
                            name: name,         // Must have
                            secondaryName: secondaryName,
                            type: type,         // Not sure the point of this one ?
                            timeOfDay: timeOfDay,
                            scene: "landscape",
                            urls: urls,
                            source: self,
                            poi: [:],
                            communityPoi: communityPoi)

                        processedVideos.append(video)
                    }
                }
            }

            return processedVideos
        } catch {
            errorLog("Error retrieving content listing (old)")
            return []
        }
    }
    
    func getSubcategoryFor(_ asset: MacAsset, manifest: MacManifest) -> String {
        for category in manifest.categories {
            if category.subcategories != nil {
                for subcategory in category.subcategories! {
                    if subcategory.id == asset.subcategories.first {
                        return PoiStringProvider.sharedInstance.getLocalizedNameKey(key:subcategory.localizedNameKey)
                    }
                }
            }
        }
        
        return "Not found"
    }

    func getSecondaryNameFor(_ asset: VideoAsset) -> String {
        let poiStringProvider = PoiStringProvider.sharedInstance

        if let mergename = poiStringProvider.getCommunityName(id: asset.id) {
            return mergename
        } else {
            return asset.title ?? "Unknown"
        }
    }

    func getSecondaryNameFor(_ asset: MacAsset) -> String {
        let poiStringProvider = PoiStringProvider.sharedInstance

        return poiStringProvider.getLocalizedNameKey(key: asset.localizedNameKey)
    }

    
    func getSceneFor(_ asset: VideoAsset) -> String {
        if let updatedScene = SourceInfo.getSceneForVideo(id: asset.id) {
            return updatedScene.rawValue.lowercased()
        } else {
            return asset.scene ?? "landscape"
        }
    }

    func getSceneFor(_ asset: MacAsset) -> String {
        if let updatedScene = SourceInfo.getSceneForVideo(id: asset.id) {
            return updatedScene.rawValue.lowercased()
        } else {
            return "landscape"
        }
    }

    
    // Generate URLs
    func urlsFor(_ asset: VideoAsset) -> [VideoFormat: String] {
        return [.v1080pH264: localizePath(asset.url1080H264),
                .v1080pHEVC: localizePath(asset.url1080SDR),
                .v1080pHDR: localizePath(asset.url1080HDR),
                .v4KHEVC: localizePath(asset.url4KSDR),
                .v4KHDR: localizePath(asset.url4KHDR),
                .v4KSDR240: localizePath(asset.url4KSDR240FPS) ]
    }

    // Mac manifest only has 240 fps
    func urlsFor(_ asset: MacAsset) -> [VideoFormat: String] {
        return [.v1080pH264: "",
                .v1080pHEVC: "",
                .v1080pHDR: "",
                .v4KHEVC: "",
                .v4KHDR: "",
                .v4KSDR240: localizePath(asset.url4KSDR240FPS) ]
    }
    
    func oldUrlsFor(_ asset: VideoAsset) -> [VideoFormat: String] {
        var url1080pHEVC = ""
        var url1080pHDR = ""
        var url4KHEVC = ""
        var url4KHDR = ""

        // Check if we have some HEVC urls to merge
        if let val = SourceInfo.mergeInfo[asset.id] {
            url1080pHEVC = val["url-1080-SDR"]!
            url1080pHDR = val["url-1080-HDR"]!
            url4KHEVC = val["url-4K-SDR"]!
            url4KHDR = val["url-4K-HDR"]!
        }

        return [.v1080pH264: asset.url ?? "",
                .v1080pHEVC: url1080pHEVC,
                .v1080pHDR: url1080pHDR,
                .v4KHEVC: url4KHEVC,
                .v4KHDR: url4KHDR ]
    }

    func parseOldVideoManifest(_ data: Data) -> [AerialVideo] {
        do {
            let oldVideoManifest = try newJSONDecoder().decode(OldVideoManifest.self, from: data)
            var processedVideos: [AerialVideo] = []

            for group in oldVideoManifest {
                for asset in group.assets {
                    let (isDupe, foundDupe) = SourceInfo.findDuplicate(id: asset.id, url1080pH264: asset.url ?? "")

                    if isDupe {
                        if let dupe = foundDupe {
                            if dupe.urls[.v1080pH264] == "" {
                                dupe.urls[.v1080pH264] = asset.url
                            }
                        }
                    } else {
                        var poi: [String: String]?
                        if let mergeId = SourceInfo.mergePOI[asset.id] {
                            let poiStringProvider = PoiStringProvider.sharedInstance
                            poi = poiStringProvider.fetchExtraPoiForId(id: mergeId)
                        }

                        let video = AerialVideo(id: asset.id,
                            name: asset.accessibilityLabel,
                            secondaryName: getSecondaryNameFor(asset),
                            type: "video",
                            timeOfDay: asset.timeOfDay ?? "day",
                            scene: getSceneFor(asset),
                            urls: oldUrlsFor(asset),
                            source: self,
                            poi: poi ?? [:],
                            communityPoi: PoiStringProvider.sharedInstance.getCommunityPoi(id: asset.id))

                        processedVideos.append(video)
                    }
                }
            }

            return processedVideos
        } catch let error {
            debugLog(error.localizedDescription)
            errorLog("### Could not parse manifest data")
            return []
        }
    }

    func readVideoManifest(_ data: Data) -> [AerialVideo] {
        if let videoManifest = try? newJSONDecoder().decode(VideoManifest.self, from: data) {
            var processedVideos: [AerialVideo] = []

            for asset in videoManifest.assets {
                let video = AerialVideo(id: asset.id,
                    name: asset.accessibilityLabel,
                    secondaryName: getSecondaryNameFor(asset),
                    type: "video",
                    timeOfDay: asset.timeOfDay ?? "day",
                    scene: getSceneFor(asset),
                    urls: urlsFor(asset),
                    source: self,
                    poi: asset.pointsOfInterest ?? [:],
                    communityPoi: PoiStringProvider.sharedInstance.getCommunityPoi(id: asset.id))

                processedVideos.append(video)
            }

            return processedVideos
        }

        errorLog("### Could not parse manifest data")
        return []
    }

    func parseVideoManifest(_ data: Data) -> [AerialVideo] {
        if let videoManifest = try? newJSONDecoder().decode(VideoManifest.self, from: data) {
            var processedVideos: [AerialVideo] = []

            for asset in videoManifest.assets {
                let (isDupe, foundVideo) = SourceInfo.findDuplicate(id: asset.id, url1080pH264: asset.url1080H264 ?? "")

                if !isDupe {
                    let video = AerialVideo(id: asset.id,
                        name: asset.accessibilityLabel,
                        secondaryName: getSecondaryNameFor(asset),
                        type: "video",
                        timeOfDay: asset.timeOfDay ?? "day",
                        scene: getSceneFor(asset),
                        urls: urlsFor(asset),
                        source: self,
                        poi: asset.pointsOfInterest ?? [:],
                        communityPoi: PoiStringProvider.sharedInstance.getCommunityPoi(id: asset.id))

                    processedVideos.append(video)
                } else {
                    // Merge urls with macOS manifest
                    let assetURLs = urlsFor(asset)
                    if foundVideo?.urls[.v4KHDR] == "" {
                        foundVideo?.urls[.v4KHDR] = assetURLs[.v4KHDR]
                    }
                    if foundVideo?.urls[.v4KHEVC] == "" {
                        foundVideo?.urls[.v4KHEVC] = assetURLs[.v4KHEVC]
                    }
                    if foundVideo?.urls[.v1080pHDR] == "" {
                        foundVideo?.urls[.v1080pHDR] = assetURLs[.v1080pHDR]
                    }
                    if foundVideo?.urls[.v1080pHEVC] == "" {
                        foundVideo?.urls[.v1080pHEVC] = assetURLs[.v1080pHEVC]
                    }
                    if foundVideo?.urls[.v1080pH264] == "" {
                        foundVideo?.urls[.v1080pH264] = assetURLs[.v1080pH264]
                    }
                }
            }

            return processedVideos
        }

        errorLog("### Could not parse manifest data")
        return []
    }
    
    func parseMacManifest(_ data: Data) -> [AerialVideo] {
        if let videoManifest = try? newJSONDecoder().decode(MacManifest.self, from: data) {
            var processedVideos: [AerialVideo] = []

            for asset in videoManifest.assets {
                let (isDupe, _) = SourceInfo.findDuplicate(id: asset.id, url1080pH264: "")

                if !isDupe {
                    let video = AerialVideo(id: asset.id,
                        name: getSubcategoryFor(asset, manifest: videoManifest),
                        secondaryName: getSecondaryNameFor(asset),
                        type: "video",
                        timeOfDay: "day",
                        scene: getSceneFor(asset),
                        urls: urlsFor(asset),
                        source: self,
                        poi: asset.pointsOfInterest, // ?? [:],
                        communityPoi: PoiStringProvider.sharedInstance.getCommunityPoi(id: asset.id))

                    processedVideos.append(video)
                }
            }

            return processedVideos
        }

        errorLog("### Could not parse manifest data")
        return []
    }
    
}

// MARK: - VideoManifest
/// The newer format used by all our other JSONs
struct VideoManifest: Codable {
    let assets: [VideoAsset]
    let initialAssetCount, version: Int?
}

// MARK: - OldVideoManifestElement
/// This is tvOS 10's manifest format
struct OldVideoManifestElement: Codable {
    let id: String
    let assets: [VideoAsset]
}

typealias OldVideoManifest = [OldVideoManifestElement]

// MARK: - VideoAsset
/// Common Asset structure for all our JSONs
///
/// I've added multiple extra fields that aren't in Apple's JSONs, including:
/// - title: as in Los Angeles (accesibilityLabel) / Santa Monica Beach (title)
/// - timeOfDay: only on tvOS 10, resurected for custom sources, can also be sunset or sunrise
/// - scene: landscape, city, space, sea
struct VideoAsset: Codable {
    let accessibilityLabel, id: String
    let title: String?
    let timeOfDay: String?
    let scene: String?
    let pointsOfInterest: [String: String]?
    let url4KHDR, url4KSDR, url1080H264, url1080HDR, url4KSDR120FPS, url4KSDR240FPS: String?
    let url1080SDR, url: String?
    let type: String?

    enum CodingKeys: String, CodingKey {
        case accessibilityLabel, id, pointsOfInterest
        case title, timeOfDay, scene
        case url4KHDR = "url-4K-HDR"
        case url4KSDR = "url-4K-SDR"
        case url1080H264 = "url-1080-H264"
        case url1080HDR = "url-1080-HDR"
        case url1080SDR = "url-1080-SDR"
        case url4KSDR240FPS = "url-4K-SDR-240FPS"
        case url4KSDR120FPS = "url-4K-SDR-120FPS"
        case url
        case type
    }
}

// MARK: - MACManifest
struct MacManifest: Codable {
    let localizationVersion: LocalizationVersion
    let categories: [SubcategoryElement]
    let initialAssetCount: Int
    let assets: [MacAsset]
    let version: Int
}

// MARK: - Asset
struct MacAsset: Codable {
    let shotID: String
    let previewImage: String
    let localizedNameKey, accessibilityLabel: String
    let preferredOrder: Int
    let categories: [CategoryEnum]
    let id: String
    let subcategories: [String]
    let pointsOfInterest: [String: String]
    let url4KSDR240FPS: String
    let includeInShuffle, showInTopLevel: Bool
    let group: LocalizationVersion?

    enum CodingKeys: String, CodingKey {
        case shotID, previewImage, localizedNameKey, accessibilityLabel, preferredOrder, categories, id, subcategories, pointsOfInterest
        case url4KSDR240FPS = "url-4K-SDR-240FPS"
        case includeInShuffle, showInTopLevel, group
    }
}

enum CategoryEnum: String, Codable {
    case a33A55D9Edea4596A8506C10B54Fbbb5 = "A33A55D9-EDEA-4596-A850-6C10B54FBBB5"
    case the55B7C95DCeaf4Fd8AdefF5Bc657D8F6D = "55B7C95D-CEAF-4FD8-ADEF-F5BC657D8F6D"
    case the5Ef4117148624F93800CAd86Ce5E6891 = "5EF41171-4862-4F93-800C-AD86CE5E6891"
    case the8Be8B5246Eae43F5A3E801Dcfa1Bcd4B = "8BE8B524-6EAE-43F5-A3E8-01DCFA1BCD4B"
}

enum LocalizationVersion: String, Codable {
    case the19J1 = "19J-1"
    case the19K1 = "19K-1"
    case the21J1 = "21J-1"
}

// MARK: - SubcategoryElement
struct SubcategoryElement: Codable {
    let subcategories: [SubcategoryElement]?
    let localizedDescriptionKey, representativeAssetID: String
    let previewImage: String
    let id: String
    let preferredOrder: Int
    let localizedNameKey: String
}