iCloud support ong (#85)

This commit is contained in:
cranci 2025-04-18 14:33:26 +02:00 committed by GitHub
commit 9c5eacf1ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 260 additions and 18 deletions

View file

@ -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")
}
}
}
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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() {

View file

@ -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)
}
}

View file

@ -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 {