diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 795a3d1..9809d0e 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -15,6 +15,14 @@ struct SoraApp: App { init() { _ = iCloudSyncManager.shared + + TraktToken.checkAuthenticationStatus { isAuthenticated in + if isAuthenticated { + Logger.shared.log("Trakt authentication is valid") + } else { + Logger.shared.log("Trakt authentication required", type: "Warning") + } + } } var body: some Scene { @@ -89,4 +97,4 @@ struct SoraApp: App { Logger.shared.log("Unknown authentication service", type: "Error") } } -} +} \ No newline at end of file diff --git a/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift b/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift index a206631..6e726cf 100644 --- a/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift +++ b/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift @@ -164,5 +164,83 @@ class TraktToken { return token } + + static func getAccessToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: accessTokenKey, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let tokenData = result as? Data, + let token = String(data: tokenData, encoding: .utf8) else { + return nil + } + + return token + } + + static func validateToken(completion: @escaping (Bool) -> Void) { + guard let token = getAccessToken() else { + completion(false) + return + } + + guard let url = URL(string: "https://api.trakt.tv/users/settings") else { + completion(false) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("2", forHTTPHeaderField: "trakt-api-version") + request.setValue(clientID, forHTTPHeaderField: "trakt-api-key") + + let task = URLSession.shared.dataTask(with: request) { _, response, _ in + DispatchQueue.main.async { + if let httpResponse = response as? HTTPURLResponse { + let isValid = httpResponse.statusCode == 200 + completion(isValid) + } else { + completion(false) + } + } + } + + task.resume() + } + + static func validateAndRefreshTokenIfNeeded(completion: @escaping (Bool) -> Void) { + if getAccessToken() == nil { + if getRefreshToken() != nil { + refreshAccessToken(completion: completion) + } else { + completion(false) + } + return + } + + validateToken { isValid in + if isValid { + completion(true) + } else { + if getRefreshToken() != nil { + refreshAccessToken(completion: completion) + } else { + completion(false) + } + } + } + } + + static func checkAuthenticationStatus(completion: @escaping (Bool) -> Void) { + validateAndRefreshTokenIfNeeded(completion: completion) + } } - diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 929b00b..8ffd621 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -92,6 +92,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele var backwardButton: UIImageView! var forwardButton: UIImageView! var subtitleLabel: UILabel! + var topSubtitleLabel: UILabel! var dismissButton: UIButton! var menuButton: UIButton! var watchNextButton: UIButton! @@ -215,7 +216,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele do { try audioSession.setActive(true) } catch { - print("Error activating audio session: \(error)") + Logger.shared.log("Error activating audio session: \(error)", type: "Debug") } volumeViewModel.value = Double(audioSession.outputVolume) @@ -652,6 +653,21 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele subtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36), subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36) ]) + + topSubtitleLabel = UILabel() + topSubtitleLabel.textAlignment = .center + topSubtitleLabel.numberOfLines = 0 + topSubtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize)) + topSubtitleLabel.isHidden = true + view.addSubview(topSubtitleLabel) + topSubtitleLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + topSubtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + topSubtitleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 30), + topSubtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36), + topSubtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36) + ]) } func updateSubtitleLabelConstraints() { @@ -949,6 +965,16 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele subtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius) subtitleLabel.layer.shadowOpacity = 1.0 subtitleLabel.layer.shadowOffset = CGSize.zero + + topSubtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize)) + topSubtitleLabel.textColor = subtitleUIColor() + topSubtitleLabel.backgroundColor = subtitleBackgroundEnabled ? UIColor.black.withAlphaComponent(0.6) : .clear + topSubtitleLabel.layer.cornerRadius = 5 + topSubtitleLabel.clipsToBounds = true + topSubtitleLabel.layer.shadowColor = UIColor.black.cgColor + topSubtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius) + topSubtitleLabel.layer.shadowOpacity = 1.0 + topSubtitleLabel.layer.shadowOffset = CGSize.zero } func subtitleUIColor() -> UIColor { @@ -985,11 +1011,27 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)") UserDefaults.standard.set(self.duration, forKey: "totalTime_\(self.fullUrl)") - if self.subtitlesEnabled, - let currentCue = self.subtitlesLoader.cues.first(where: { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime }) { - self.subtitleLabel.text = currentCue.text.strippedHTML + if self.subtitlesEnabled { + let cues = self.subtitlesLoader.cues.filter { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime } + if cues.count > 0 { + self.subtitleLabel.text = cues[0].text.strippedHTML + self.subtitleLabel.isHidden = false + } else { + self.subtitleLabel.text = "" + self.subtitleLabel.isHidden = !self.subtitlesEnabled + } + if cues.count > 1 { + self.topSubtitleLabel.text = cues[1].text.strippedHTML + self.topSubtitleLabel.isHidden = false + } else { + self.topSubtitleLabel.text = "" + self.topSubtitleLabel.isHidden = true + } } else { self.subtitleLabel.text = "" + self.subtitleLabel.isHidden = true + self.topSubtitleLabel.text = "" + self.topSubtitleLabel.isHidden = true } DispatchQueue.main.async { diff --git a/Sora/Utils/Modules/ModuleManager.swift b/Sora/Utils/Modules/ModuleManager.swift index d13d8cf..b3526ba 100644 --- a/Sora/Utils/Modules/ModuleManager.swift +++ b/Sora/Utils/Modules/ModuleManager.swift @@ -29,6 +29,24 @@ class ModuleManager: ObservableObject { let url = getModulesFilePath() guard let data = try? Data(contentsOf: url) else { return } modules = (try? JSONDecoder().decode([ScrapingModule].self, from: data)) ?? [] + + Task { + for module in modules { + let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath) + if (!fileManager.fileExists(atPath: localUrl.path)) { + do { + let scriptUrl = URL(string: module.metadata.scriptUrl) + guard let scriptUrl = scriptUrl else { continue } + let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl) + guard let jsContent = String(data: scriptData, encoding: .utf8) else { continue } + try jsContent.write(to: localUrl, atomically: true, encoding: .utf8) + Logger.shared.log("Recovered missing JS file for module: \(module.metadata.sourceName)") + } catch { + Logger.shared.log("Failed to recover JS file for module: \(module.metadata.sourceName) - \(error.localizedDescription)") + } + } + } + } } private func saveModules() { diff --git a/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift index 9a9c3a1..a7cf958 100644 --- a/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift +++ b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift @@ -26,9 +26,20 @@ class iCloudSyncManager { "sendPushUpdates", "sendTraktUpdates", "bookmarkedItems", - "continueWatchingItems" + "continueWatchingItems", + "analyticsEnabled", + "refreshModulesOnLaunch", + "fetchEpisodeMetadata", + "multiThreads", + "metadataProviders" ] + private let modulesFileName = "modules.json" + + private var ubiquityContainerURL: URL? { + FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") + } + private init() { setupSync() @@ -37,23 +48,43 @@ class iCloudSyncManager { private func setupSync() { NSUbiquitousKeyValueStore.default.synchronize() - syncFromiCloud() - + syncModulesFromiCloud() NotificationCenter.default.addObserver(self, selector: #selector(iCloudDidChangeExternally), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default) - NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: nil) } @objc private func willEnterBackground() { syncToiCloud() + syncModulesToiCloud() + } + + private func allProgressKeys() -> [String] { + let allKeys = UserDefaults.standard.dictionaryRepresentation().keys + let progressPrefixes = ["lastPlayedTime_", "totalTime_"] + return allKeys.filter { key in + progressPrefixes.contains { prefix in key.hasPrefix(prefix) } + } + } + + private func allKeysToSync() -> [String] { + var keys = Set(defaultsToSync + allProgressKeys()) + let userDefaults = UserDefaults.standard + let all = userDefaults.dictionaryRepresentation() + for (key, value) in all { + if key.hasPrefix("Apple") || key.hasPrefix("_") { continue } + if value is Int || value is Double || value is Bool || value is String { + keys.insert(key) + } + } + return Array(keys) } private func syncFromiCloud() { let iCloud = NSUbiquitousKeyValueStore.default let defaults = UserDefaults.standard - for key in defaultsToSync { + for key in allKeysToSync() { if let value = iCloud.object(forKey: key) { defaults.set(value, forKey: key) } @@ -67,7 +98,7 @@ class iCloudSyncManager { let iCloud = NSUbiquitousKeyValueStore.default let defaults = UserDefaults.standard - for key in defaultsToSync { + for key in allKeysToSync() { if let value = defaults.object(forKey: key) { iCloud.set(value, forKey: key) } @@ -79,16 +110,79 @@ class iCloudSyncManager { @objc private func iCloudDidChangeExternally(_ notification: Notification) { guard let userInfo = notification.userInfo, let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else { - return - } - + return + } if reason == NSUbiquitousKeyValueStoreServerChange || - reason == NSUbiquitousKeyValueStoreInitialSyncChange { + reason == NSUbiquitousKeyValueStoreInitialSyncChange { syncFromiCloud() + syncModulesFromiCloud() } } @objc private func userDefaultsDidChange(_ notification: Notification) { syncToiCloud() } + + func syncModulesToiCloud() { + DispatchQueue.global(qos: .background).async { + guard let iCloudURL = self.ubiquityContainerURL else { return } + let localModulesURL = self.getLocalModulesFileURL() + let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName) + do { + guard FileManager.default.fileExists(atPath: localModulesURL.path) else { return } + + let shouldCopy: Bool + if FileManager.default.fileExists(atPath: iCloudModulesURL.path) { + let localData = try Data(contentsOf: localModulesURL) + let iCloudData = try Data(contentsOf: iCloudModulesURL) + shouldCopy = localData != iCloudData + } else { + shouldCopy = true + } + + if shouldCopy { + if FileManager.default.fileExists(atPath: iCloudModulesURL.path) { + try FileManager.default.removeItem(at: iCloudModulesURL) + } + try FileManager.default.copyItem(at: localModulesURL, to: iCloudModulesURL) + } + } catch { + Logger.shared.log("iCloud modules sync error: \(error)", type: "Error") + } + } + } + + func syncModulesFromiCloud() { + DispatchQueue.global(qos: .background).async { + guard let iCloudURL = self.ubiquityContainerURL else { return } + let localModulesURL = self.getLocalModulesFileURL() + let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName) + do { + guard FileManager.default.fileExists(atPath: iCloudModulesURL.path) else { return } + + let shouldCopy: Bool + if FileManager.default.fileExists(atPath: localModulesURL.path) { + let localData = try Data(contentsOf: localModulesURL) + let iCloudData = try Data(contentsOf: iCloudModulesURL) + shouldCopy = localData != iCloudData + } else { + shouldCopy = true + } + + if shouldCopy { + if FileManager.default.fileExists(atPath: localModulesURL.path) { + try FileManager.default.removeItem(at: localModulesURL) + } + try FileManager.default.copyItem(at: iCloudModulesURL, to: localModulesURL) + } + } catch { + Logger.shared.log("iCloud modules fetch error: \(error)", type: "Error") + } + } + } + + private func getLocalModulesFileURL() -> URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return docs.appendingPathComponent(modulesFileName) + } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index 3332bb1..ae8977c 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -25,7 +25,7 @@ struct SettingsViewTrackers: View { var body: some View { Form { - Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.\n\nNote that progresses update may not be 100% acurate.")) { + Section(header: Text("AniList")) { HStack() { KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png")) .placeholder { @@ -74,7 +74,7 @@ struct SettingsViewTrackers: View { .font(.body) } - Section(header: Text("Trakt"), footer: Text("Sora and cranci1 are not affiliated with Trakt in any way.\n\nNote that progress updates may not be 100% accurate.")) { + Section(header: Text("Trakt")) { HStack() { KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png")) .placeholder { @@ -116,6 +116,8 @@ struct SettingsViewTrackers: View { } .font(.body) } + + Section(footer: Text("Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate.")) {} } .navigationTitle("Trackers") .onAppear {