JohnCoates/Aerial

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

Summary

Maintainability
D
3 days
Test Coverage
//
//  SourceList.swift
//  Aerial
//
//  Created by Guillaume Louel on 01/07/2020.
//  Copyright © 2020 Guillaume Louel. All rights reserved.
//

import Foundation

struct SourceHeader {
    let name: String
    let sources: [Source]
}

// swiftlint:disable:next type_body_length
struct SourceList {
    // This is the current one until next fall
    static let macOS14 = Source(name: "macOS 14",
                        description: "High framerate videos from macOS 14 Sonoma",
                        manifestUrl: "https://sylvan.apple.com/itunes-assets/Aerials126/v4/82/2e/34/822e344c-f5d2-878c-3d56-508d5b09ed61/resources-14-0-10.tar",
                        type: .macOS,
                        scenes: [.nature, .city, .space, .sea],
                        isCachable: true,
                        license: "",
                        more: "")

    // This is the current one until next fall
    static let tvOS16 = Source(name: "tvOS 16",
                        description: "Apple TV screensavers from tvOS 16",
                        manifestUrl: "https://sylvan.apple.com/Aerials/resources-16.tar",
                        type: .tvOS12,
                        scenes: [.nature, .city, .space, .sea],
                        isCachable: true,
                        license: "",
                        more: "")

    // Legacy sources
    static let tvOS13 = Source(name: "tvOS 13",
                        description: "Apple TV screensavers from tvOS 13",
                        manifestUrl: "https://sylvan.apple.com/Aerials/resources-13.tar",
                        type: .tvOS12,
                        scenes: [.nature, .city, .space, .sea],
                        isCachable: true,
                        license: "",
                        more: "")

    /*static let tvOS12 = Source(name: "tvOS 12",
                        description: "Apple TV screensavers from tvOS 12",
                        manifestUrl: "https://sylvan.apple.com/Aerials/resources.tar",
                        type: .tvOS12,
                        scenes: [.nature, .city, .space],
                        isCachable: true,
                        license: "",
                        more: "")

    static let tvOS11 = Source(name: "tvOS 11",
                        description: "Apple TV screensavers from tvOS 11",
                        manifestUrl: "https://sylvan.apple.com/Aerials/2x/entries.json",
                        type: .tvOS11,
                        scenes: [.nature, .city],
                        isCachable: true,
                        license: "",
                        more: "")*/

   /* static let tvOS10 = Source(name: "tvOS 10",
                        description: "Apple TV screensavers from tvOS 10",
                        manifestUrl: "http://a1.phobos.apple.com/us/r1000/000/Features/atv/AutumnResources/videos/entries.json",
                        type: .tvOS10,
                        scenes: [.nature, .city],
                        isCachable: true,
                        license: "",
                        more: "")*/

    static var list: [Source] = [macOS14, tvOS16, tvOS13] + foundSources
    // static var list: [Source] = foundSources

    // This is where the magic happens
    static var foundSources: [Source] {
        var sources: [Source] = []
        var foundCommunity = false

        for folder in URL(fileURLWithPath: Cache.supportPath).subDirectories {
            if !folder.lastPathComponent.starts(with: "tvOS")
                && !folder.lastPathComponent.starts(with: "macOS")
                && !folder.lastPathComponent.starts(with: "backups")
                && !folder.lastPathComponent.starts(with: "Thumbnails")
                && !folder.lastPathComponent.starts(with: "Cache") {

                if folder.lastPathComponent.starts(with: "Community") || folder.lastPathComponent.starts(with: "From") {
                    foundCommunity = true
                }

                // If it's valid, let's add !
                if let source = loadManifest(url: folder) {
                    sources.append(source)
                } else if let newsources = loadMetaManifest(url: folder) {
                    sources.append(contentsOf: newsources)
                }
            }
        }

        if !foundCommunity {
            DispatchQueue.main.async {
                fetchOnlineManifest(url: URL(string: "https://aerialscreensaver.github.io/community/")!)
            }
        }

        return sources
    }

    // swiftlint:disable for_where
    static func hasNamed(name: String) -> Bool {
        for source in list where source.type == .local {
            if source.name == name {
                return true
            }
        }

        return false
    }

    static func categorizedSourceList() -> [SourceHeader] {
        var communities: [Source] = []
        var online: [Source] = []
        var local: [Source] = []
        var apple: [Source] = []

        for source in list { // where !source.name.starts(with: "tvOS") {
            if source.type == .local {
                local.append(source)
            } else {
                // This may need to be changed in the future
                if !source.isCachable {
                    online.append(source)
                } else if source.name.starts(with: "tvOS") || source.name.starts(with: "macOS") {
                    apple.append(source)
                } else {
                    communities.append(source)
                }
            }
        }

        // Then we build our list
        var output: [SourceHeader] = []

        if !communities.isEmpty {
            output.append(SourceHeader(name: "Community Videos", sources: communities))
        }

        if !online.isEmpty {
            output.append(SourceHeader(name: "Online Sources", sources: online))
        }

        if !apple.isEmpty {
            output.append(SourceHeader(name: "Apple", sources: apple))
        }

        if !local.isEmpty {
            output.append(SourceHeader(name: "Local Sources", sources: local))
        }

        return output
    }

    static func fetchOnlineManifest(url: URL) {
        if let source = loadManifest(url: url) {
            debugLog("Source loaded")
            // Then save !
            let downloadManager = DownloadManager()
            downloadManager.queueDownload(url.appendingPathComponent("manifest.json"), folder: source.name)

            downloadManager.queueDownload(URL(string: source.manifestUrl)!, folder: source.name)
            list.append(source)

            source.setEnabled(true) // This will reload the main video list
        } else if let sources = loadMetaManifest(url: url) {
            debugLog("Sources loaded")

            for source in sources {
                // Then save !
                saveSource(source)

                let downloadManager = DownloadManager()
                downloadManager.queueDownload(URL(string: source.manifestUrl)!, folder: source.name)
                list.append(source)

                source.setEnabled(true) // This will reload the main video list
            }
        } else {
            debugLog("Something went wrong here")
            let task = URLSession.shared.dataTask(with: url) { _, response, error in

                if let error = error {
                    debugLog("Can't load file, possible firewall issue")
                    DispatchQueue.main.async {
                        Aerial.helper.showErrorAlert(question: "An error occured loading the file",
                            text: "Please check your network connection, firewall, and try again. \n\nError : \(error.localizedDescription)")
                    }
                    return
                }
                guard let response = response as? HTTPURLResponse else {
                    debugLog("No HTTP response")

                    DispatchQueue.main.async {
                        Aerial.helper.showErrorAlert(question: "No HTTP Response",
                                              text: "Please check your network connection, firewall, and try again.")
                    }
                    return
                }

                if response.statusCode != 200 {
                    DispatchQueue.main.async {
                        debugLog("HTTP error")

                        Aerial.helper.showErrorAlert(question: "HTTP Error",
                            text: "Please verify the URL (and check your network connexion and firewall). HTTP error: \(response.statusCode)")
                    }
                    return
                } else {
                    DispatchQueue.main.async {
                        debugLog("Incorect JSON format")

                        Aerial.helper.showErrorAlert(question: "Incorrect JSON Format",
                                              text: "Your URL was valid, but the file is not in the correct format. Please check the URL.")
                    }
                    return
                }
            }
            task.resume()
        }
    }

    static func updateLocalSource(source: Source, reload: Bool) {
        // We need the raw manifest to find the path inside
        let videos = source.getUnprocessedVideos()
        let originalAssets = source.getUnprocessedAssets()

        var updatedAssets = [VideoAsset]()
        
        if videos.count >= 1 {
            let url = videos.first!.url.deletingLastPathComponent()
            let folderName = url.lastPathComponent
            debugLog("processing url for videos : \(url)")

            do {
                let urls = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
                // var assets = [VideoAsset]()

                
                
                for lurl in urls {
                    if lurl.path.lowercased().hasSuffix(".mp4") || lurl.path.lowercased().hasSuffix(".mov") {

                        // Check if the asset was there previously
                        let foundAssets = originalAssets.filter { $0.url4KSDR == lurl.path }

                        if let foundAsset = foundAssets.first {
                            // Just add the asset to the new array
                            updatedAssets.append(foundAsset)
                        } else {
                            // Create a new entry
                            updatedAssets.append(VideoAsset(accessibilityLabel: folderName,
                                                     id: NSUUID().uuidString,
                                                     title: lurl.lastPathComponent,
                                                     timeOfDay: "day",
                                                     scene: "",
                                                     pointsOfInterest: [:],
                                                     url4KHDR: "",
                                                     url4KSDR: lurl.path,
                                                     url1080H264: "",
                                                     url1080HDR: "",
                                                     url4KSDR120FPS: "",
                                                     url4KSDR240FPS: "",
                                                     url1080SDR: "",
                                                     url: "",
                                                     type: "nature"))
                        }
                    }
                }

                debugLog("Updating manifest \(url.lastPathComponent)")

                let videoManifest = VideoManifest(assets: updatedAssets, initialAssetCount: 1, version: 1)

                SourceList.saveEntries(source: source, manifest: videoManifest)

                if reload {
                    VideoList.instance.reloadSources()
                }
            } catch {
                errorLog("Could not process directory")
            }
        } else {
            debugLog("Cannot parse your directory, did you delete your videos ?")
        }

    }

    static func processPathForVideos(url: URL) {
        debugLog("processing url for videos : \(url) ")
        let folderName = url.lastPathComponent

        do {
            let urls = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
            var assets = [VideoAsset]()

            for lurl in urls {
                if lurl.path.lowercased().hasSuffix(".mp4") || lurl.path.lowercased().hasSuffix(".mov") {
                    assets.append(VideoAsset(accessibilityLabel: folderName,
                                             id: NSUUID().uuidString,
                                             title: lurl.lastPathComponent,
                                             timeOfDay: "day",
                                             scene: "",
                                             pointsOfInterest: [:],
                                             url4KHDR: "",
                                             url4KSDR: lurl.path,
                                             url1080H264: "",
                                             url1080HDR: "",
                                             url4KSDR120FPS: "",
                                             url4KSDR240FPS: "",
                                             url1080SDR: "",
                                             url: "",
                                             type: "nature"))
                }
            }

            // ...
            if SourceList.hasNamed(name: url.lastPathComponent) {
                Aerial.helper.showInfoAlert(title: "Source name mismatch",
                                     text: "A source with this name already exists. Try renaming your folder and try again.")
            } else {
                debugLog("Creating source \(url.lastPathComponent)")

                // Generate and save the Source
                let source = Source(name: url.lastPathComponent,
                                    description: "Local files from \(url.path)",
                                    manifestUrl: "manifest.json",
                                    type: .local,
                                    scenes: [.nature],
                                    isCachable: false,
                                    license: "",
                                    more: "")

                SourceList.saveSource(source)

                // Then the entries
                let videoManifest = VideoManifest(assets: assets, initialAssetCount: 1, version: 1)

                SourceList.saveEntries(source: source, manifest: videoManifest)

                list.append(source)
                VideoList.instance.reloadSources()
            }
        } catch {
            errorLog("Could not process directory")
        }
    }

    static func saveSource(_ source: Source) {
        let manifest = Manifest.init(name: source.name,
                                     manifestDescription: source.description,
                                     scenes: source.scenes.map({ $0.rawValue }),
                                     local: source.type == .local,
                                     cacheable: source.isCachable,
                                     manifestUrl: source.manifestUrl,
                                     license: source.license,
                                     more: source.more)

        // First make the folder
        FileHelpers.createDirectory(atPath: Cache.supportPath.appending("/"+source.name))

        let json = try? JSONEncoder().encode(manifest)

        do {
            try json!.write(to: URL(fileURLWithPath:
                                    Cache.supportPath.appending("/"+source.name+"/manifest.json")))
        } catch {
            errorLog("Can't save local source : \(error.localizedDescription)")
        }
    }

    static func saveEntries(source: Source, manifest: VideoManifest) {
        let json = try? JSONEncoder().encode(manifest)

        do {
            try json!.write(to: URL(fileURLWithPath:
                                    Cache.supportPath.appending("/"+source.name+"/entries.json")))
        } catch {
            errorLog("Can't save local entries : \(error.localizedDescription)")
        }
    }
    static func loadMetaManifest(url: URL) -> [Source]? {
        // Let's make sure we have the required files
        if !areManifestPresent(url: url) && !url.absoluteString.starts(with: "http") {
            return nil
        }

        do {
            let jsonData = try Data(contentsOf: url.appendingPathComponent("manifest.json"))

            if let metamanifest = try? newJSONDecoder().decode(MetaManifest.self, from: jsonData) {
                var sources: [Source] = []

                for manifest in metamanifest.sources {
                    sources.append(parseSourceFromManifest(manifest, url: url))
                }

                return sources
            }
        } catch {
            errorLog("Could not open manifest for source at \(url)")
            return nil
        }

        return nil
    }

    static func loadManifest(url: URL) -> Source? {
        // Let's make sure we have the required files
        if !areManifestPresent(url: url) && !url.absoluteString.starts(with: "http") {
            return nil
        }

        do {
            let jsonData = try Data(contentsOf: url.appendingPathComponent("manifest.json"))
            if let manifest = try? newJSONDecoder().decode(Manifest.self, from: jsonData) {
                debugLog("Manifest opened, going to parsing")
                return parseSourceFromManifest(manifest, url: nil)
            }
        } catch {
            errorLog("Could not open manifest for source at \(url)")
            return nil
        }

        return nil
    }

    static private func parseSourceFromManifest(_ manifest: Manifest, url: URL?) -> Source {
        var local = true
        var mURL: String
        if let isLocal = manifest.local {
            local = isLocal
        }

        if local {
            mURL = (url != nil) ? url!.absoluteString : manifest.manifestUrl ?? ""
        } else {
            mURL = manifest.manifestUrl ?? ""
        }

        let cacheable: Bool = manifest.cacheable ?? !local

        debugLog("Parsed \(manifest.name)")

        return Source(name: manifest.name,
                      description: manifest.manifestDescription,
                      manifestUrl: mURL,
                      type: local ? .local : .tvOS12,
                      scenes: jsonToSceneArray(array: manifest.scenes ?? []),
                      isCachable: cacheable,
                      license: manifest.license ?? "",
                      more: manifest.more ?? "")
    }

    /// Helper to convert an array of strings to an array of sources
    ///
    /// ["landscape"] -> [.landscape]
    static func jsonToSceneArray(array: [String]) -> [SourceScene] {
        var output: [SourceScene] = []
        for scene in array {
            switch scene {
            case "sea":
                output.append(.sea)
            case "space":
                output.append(.space)
            case "city":
                output.append(.city)
            case "beach":
                output.append(.beach)
            case "countryside":
                output.append(.countryside)
            default:
                output.append(.nature)
            }
        }

        return output
    }

    static func areManifestPresent(url: URL) -> Bool {
        // For a source to be valid we at the very least need two things
        // manifest.json    <- a description of the source
        // entries.json     <- the classic video manifest
        return FileManager.default.fileExists(atPath: url.path.appending("/entries.json")) ||
           FileManager.default.fileExists(atPath: url.path.appending("/manifest.json"))
    }

}

// MARK: - MetaManifest
struct MetaManifest: Codable {
    let sources: [Manifest]
}

// MARK: - Manifest
struct Manifest: Codable {
    let name, manifestDescription: String
    let scenes: [String]?
    let local: Bool?
    let cacheable: Bool?
    let manifestUrl: String?
    let license: String?
    let more: String?

    enum CodingKeys: String, CodingKey {
        case name
        case manifestDescription = "description"
        case scenes
        case local
        case cacheable
        case manifestUrl
        case license
        case more
    }
}