From 72fce2f14bc21c9c14187b7472e2d02e4eb606f3 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Wed, 30 Apr 2025 21:59:51 +0200 Subject: [PATCH 1/4] test download --- Sora/ContentView.swift | 4 + .../ContinueWatching/DownloadManager.swift | 95 -------- .../DownloadManager/DownloadManager.swift | 209 ++++++++++++++++++ Sora/Views/DownloadView.swift | 146 +++++++++--- .../EpisodeCell/EpisodeCell.swift | 12 +- Sora/Views/MediaInfoView/MediaInfoView.swift | 77 ++++++- Sulfur.xcodeproj/project.pbxproj | 2 +- 7 files changed, 412 insertions(+), 133 deletions(-) delete mode 100644 Sora/Utils/ContinueWatching/DownloadManager.swift create mode 100644 Sora/Utils/DownloadManager/DownloadManager.swift diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index afbd4ea..cf1b64f 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -15,6 +15,10 @@ struct ContentView: View { .tabItem { Label("Library", systemImage: "books.vertical") } + DownloadView() + .tabItem { + Label("Download", systemImage: "arrow.down.to.line.circle") + } SearchView() .tabItem { Label("Search", systemImage: "magnifyingglass") diff --git a/Sora/Utils/ContinueWatching/DownloadManager.swift b/Sora/Utils/ContinueWatching/DownloadManager.swift deleted file mode 100644 index 9c3d90d..0000000 --- a/Sora/Utils/ContinueWatching/DownloadManager.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// DownloadManager.swift -// Sulfur -// -// Created by Francesco on 29/04/25. -// - -import SwiftUI -import AVKit -import AVFoundation - -class DownloadManager: NSObject, ObservableObject { - @Published var activeDownloads: [(URL, Double)] = [] - @Published var localPlaybackURL: URL? - - private var assetDownloadURLSession: AVAssetDownloadURLSession! - private var activeDownloadTasks: [URLSessionTask: URL] = [:] - - override init() { - super.init() - initializeDownloadSession() - loadLocalContent() - } - - private func initializeDownloadSession() { - let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader") - assetDownloadURLSession = AVAssetDownloadURLSession( - configuration: configuration, - assetDownloadDelegate: self, - delegateQueue: .main - ) - } - - func downloadAsset(from url: URL) { - let asset = AVURLAsset(url: url) - let task = assetDownloadURLSession.makeAssetDownloadTask( - asset: asset, - assetTitle: "Offline Video", - assetArtworkData: nil, - options: nil - ) - - task?.resume() - activeDownloadTasks[task!] = url - } - - private func loadLocalContent() { - guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - - do { - let contents = try FileManager.default.contentsOfDirectory( - at: documents, - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles - ) - - if let localURL = contents.first(where: { $0.pathExtension == "movpkg" }) { - localPlaybackURL = localURL - } - } catch { - print("Error loading local content: \(error)") - } - } -} - -extension DownloadManager: AVAssetDownloadDelegate { - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { - activeDownloadTasks.removeValue(forKey: assetDownloadTask) - localPlaybackURL = location - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let error = error else { return } - print("Download error: \(error.localizedDescription)") - activeDownloadTasks.removeValue(forKey: task) - } - - func urlSession(_ session: URLSession, - assetDownloadTask: AVAssetDownloadTask, - didLoad timeRange: CMTimeRange, - totalTimeRangesLoaded loadedTimeRanges: [NSValue], - timeRangeExpectedToLoad: CMTimeRange) { - - guard let url = activeDownloadTasks[assetDownloadTask] else { return } - let progress = loadedTimeRanges - .map { $0.timeRangeValue.duration.seconds / timeRangeExpectedToLoad.duration.seconds } - .reduce(0, +) - - if let index = activeDownloads.firstIndex(where: { $0.0 == url }) { - activeDownloads[index].1 = progress - } else { - activeDownloads.append((url, progress)) - } - } -} diff --git a/Sora/Utils/DownloadManager/DownloadManager.swift b/Sora/Utils/DownloadManager/DownloadManager.swift new file mode 100644 index 0000000..7c975df --- /dev/null +++ b/Sora/Utils/DownloadManager/DownloadManager.swift @@ -0,0 +1,209 @@ +// +// DownloadManager.swift +// Sulfur +// +// Created by Francesco on 29/04/25. +// + +import SwiftUI +import AVKit +import AVFoundation + +class DownloadManager: NSObject, ObservableObject { + @Published var activeDownloads: [ActiveDownload] = [] + @Published var savedAssets: [DownloadedAsset] = [] + + private var assetDownloadURLSession: AVAssetDownloadURLSession! + private var activeDownloadTasks: [URLSessionTask: URL] = [:] + + override init() { + super.init() + initializeDownloadSession() + loadSavedAssets() + reconcileFileSystemAssets() + } + + private func initializeDownloadSession() { + let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader-\(UUID().uuidString)") + assetDownloadURLSession = AVAssetDownloadURLSession( + configuration: configuration, + assetDownloadDelegate: self, + delegateQueue: .main + ) + } + + func downloadAsset(from url: URL, module: ScrapingModule) { + guard !savedAssets.contains(where: { $0.originalURL == url }) else { return } + + var urlRequest = URLRequest(url: url) + urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Origin") + urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Referer") + + let asset = AVURLAsset(url: urlRequest.url!, options: ["AVURLAssetHTTPHeaderFieldsKey": urlRequest.allHTTPHeaderFields ?? [:]]) + + let task = assetDownloadURLSession.makeAssetDownloadTask( + asset: asset, + assetTitle: url.lastPathComponent, + assetArtworkData: nil, + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] + ) + + let download = ActiveDownload( + id: UUID(), + originalURL: url, + progress: 0, + task: task! + ) + + activeDownloads.append(download) + activeDownloadTasks[task!] = url + task?.resume() + } + + func deleteAsset(_ asset: DownloadedAsset) { + do { + try FileManager.default.removeItem(at: asset.localURL) + savedAssets.removeAll { $0.id == asset.id } + saveAssets() + } catch { + print("Error deleting asset: \(error)") + } + } + + func renameAsset(_ asset: DownloadedAsset, newName: String) { + guard let index = savedAssets.firstIndex(where: { $0.id == asset.id }) else { return } + savedAssets[index].name = newName + saveAssets() + } + + private func saveAssets() { + do { + let data = try JSONEncoder().encode(savedAssets) + UserDefaults.standard.set(data, forKey: "savedAssets") + } catch { + print("Error saving assets: \(error)") + } + } + + private func loadSavedAssets() { + guard let data = UserDefaults.standard.data(forKey: "savedAssets") else { return } + do { + savedAssets = try JSONDecoder().decode([DownloadedAsset].self, from: data) + } catch { + print("Error loading saved assets: \(error)") + } + } + + private func reconcileFileSystemAssets() { + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + do { + let fileURLs = try FileManager.default.contentsOfDirectory( + at: documents, + includingPropertiesForKeys: [.creationDateKey, .fileSizeKey], + options: .skipsHiddenFiles + ) + + for url in fileURLs where url.pathExtension == "movpkg" { + if !savedAssets.contains(where: { $0.localURL == url }) { + let newAsset = DownloadedAsset( + name: url.deletingPathExtension().lastPathComponent, + downloadDate: Date(), + originalURL: url, + localURL: url + ) + savedAssets.append(newAsset) + } + } + saveAssets() + } catch { + print("Error reconciling files: \(error)") + } + } +} + +extension DownloadManager: AVAssetDownloadDelegate { + func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { + guard let originalURL = activeDownloadTasks[assetDownloadTask] else { return } + + let newAsset = DownloadedAsset( + name: originalURL.lastPathComponent, + downloadDate: Date(), + originalURL: originalURL, + localURL: location + ) + + savedAssets.append(newAsset) + saveAssets() + cleanupDownloadTask(assetDownloadTask) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let error = error else { return } + print("Download error: \(error.localizedDescription)") + cleanupDownloadTask(task) + } + + func urlSession(_ session: URLSession, + assetDownloadTask: AVAssetDownloadTask, + didLoad timeRange: CMTimeRange, + totalTimeRangesLoaded loadedTimeRanges: [NSValue], + timeRangeExpectedToLoad: CMTimeRange) { + guard let originalURL = activeDownloadTasks[assetDownloadTask], + let downloadIndex = activeDownloads.firstIndex(where: { $0.originalURL == originalURL }) else { return } + + let progress = loadedTimeRanges + .map { $0.timeRangeValue.duration.seconds / timeRangeExpectedToLoad.duration.seconds } + .reduce(0, +) + + activeDownloads[downloadIndex].progress = progress + } + + private func cleanupDownloadTask(_ task: URLSessionTask) { + activeDownloadTasks.removeValue(forKey: task) + activeDownloads.removeAll { $0.task == task } + } +} + +struct DownloadProgressView: View { + let download: ActiveDownload + + var body: some View { + VStack(alignment: .leading) { + Text(download.originalURL.lastPathComponent) + .font(.subheadline) + ProgressView(value: download.progress) + .progressViewStyle(LinearProgressViewStyle()) + Text("\(Int(download.progress * 100))%") + .font(.caption) + } + } +} + +struct AssetRowView: View { + let asset: DownloadedAsset + + var body: some View { + VStack(alignment: .leading) { + Text(asset.name) + .font(.headline) + Text("\(asset.fileSize ?? 0) bytes • \(asset.downloadDate.formatted())") + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +struct ActiveDownload: Identifiable { + let id: UUID + let originalURL: URL + var progress: Double + let task: URLSessionTask +} + +extension URL { + static func isValidHLSURL(string: String) -> Bool { + guard let url = URL(string: string), url.pathExtension == "m3u8" else { return false } + return true + } +} diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index d98a28a..15ee58f 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -8,40 +8,120 @@ import SwiftUI import AVKit -struct DownloadView: View { - @StateObject private var viewModel = DownloadManager() - @State private var hlsURL = "https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8" +struct DownloadedAsset: Identifiable, Codable { + let id: UUID + var name: String + let downloadDate: Date + let originalURL: URL + let localURL: URL + var fileSize: Int64? - var body: some View { - NavigationView { - VStack { - TextField("Enter HLS URL", text: $hlsURL) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .padding() - - Button("Download Stream") { - viewModel.downloadAsset(from: URL(string: hlsURL)!) - } - .padding() - - List(viewModel.activeDownloads, id: \.0) { (url, progress) in - VStack(alignment: .leading) { - Text(url.absoluteString) - ProgressView(value: progress) - .progressViewStyle(LinearProgressViewStyle()) - } - } - - NavigationLink("Play Offline Content") { - if let url = viewModel.localPlaybackURL { - VideoPlayer(player: AVPlayer(url: url)) - } else { - Text("No offline content available") - } - } - .padding() - } - .navigationTitle("HLS Downloader") + init(id: UUID = UUID(), name: String, downloadDate: Date, originalURL: URL, localURL: URL) { + self.id = id + self.name = name + self.downloadDate = downloadDate + self.originalURL = originalURL + self.localURL = localURL + self.fileSize = getFileSize() + } + + func getFileSize() -> Int64? { + do { + let values = try localURL.resourceValues(forKeys: [.fileSizeKey]) + return Int64(values.fileSize ?? 0) + } catch { + return nil } } } + +struct DownloadView: View { + @StateObject private var viewModel = DownloadManager() + @State private var showingDeleteAlert = false + @State private var assetToDelete: DownloadedAsset? + @State private var renameText = "" + @State private var assetToRename: DownloadedAsset? + + var body: some View { + NavigationView { + List { + if !viewModel.activeDownloads.isEmpty { + Section("Active Downloads") { + ForEach(viewModel.activeDownloads) { download in + DownloadProgressView(download: download) + } + } + } + + if !viewModel.savedAssets.isEmpty { + Section("Completed Downloads") { + ForEach(viewModel.savedAssets) { asset in + NavigationLink { + VideoPlayer(player: AVPlayer(url: asset.localURL)) + .navigationTitle(asset.name) + } label: { + AssetRowView(asset: asset) + } + .contextMenu { + Button(action: { startRenaming(asset) }) { + Label("Rename", systemImage: "pencil") + } + + Button(role: .destructive, action: { confirmDelete(asset) }) { + Label("Delete", systemImage: "trash") + } + } + } + } + } + + if viewModel.activeDownloads.isEmpty && viewModel.savedAssets.isEmpty { + Section { + Text("No downloads") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + } + } + } + .navigationTitle("Downloads") + .alert("Delete Download", isPresented: $showingDeleteAlert) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { + if let asset = assetToDelete { + viewModel.deleteAsset(asset) + } + } + } message: { + Text("Are you sure you want to delete \(assetToDelete?.name ?? "this download")?") + } + .alert("Rename Download", isPresented: Binding( + get: { assetToRename != nil }, + set: { if !$0 { assetToRename = nil } } + )) { + TextField("Name", text: $renameText) + Button("Cancel", role: .cancel) { + renameText = "" + assetToRename = nil + } + Button("Save") { + if let asset = assetToRename { + viewModel.renameAsset(asset, newName: renameText) + } + renameText = "" + assetToRename = nil + } + } + } + } + + private func confirmDelete(_ asset: DownloadedAsset) { + assetToDelete = asset + showingDeleteAlert = true + } + + private func startRenaming(_ asset: DownloadedAsset) { + assetToRename = asset + renameText = asset.name + } +} \ No newline at end of file diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index bf41b8b..a203ac2 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -23,6 +23,7 @@ struct EpisodeCell: View { let onTap: (String) -> Void let onMarkAllPrevious: () -> Void + let onDownload: () -> Void @State private var episodeTitle: String = "" @State private var episodeImageUrl: String = "" @@ -35,12 +36,12 @@ struct EpisodeCell: View { var defaultBannerImage: String { let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light) return isLightMode - ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" - : "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" + ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" + : "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" } init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double, - itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) { + itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void, onDownload: @escaping () -> Void) { // Add the new parameter self.episodeIndex = episodeIndex self.episode = episode self.episodeID = episodeID @@ -48,6 +49,7 @@ struct EpisodeCell: View { self.itemID = itemID self.onTap = onTap self.onMarkAllPrevious = onMarkAllPrevious + self.onDownload = onDownload } var body: some View { @@ -99,6 +101,10 @@ struct EpisodeCell: View { Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill") } } + + Button(action: onDownload) { + Label("Download Episode", systemImage: "arrow.down.circle") + } } .onAppear { updateProgress() diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 6dec97e..6fc281d 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -59,7 +59,8 @@ struct MediaInfoView: View { @Environment(\.dismiss) private var dismiss @State private var orientationChanged: Bool = false - + @StateObject private var downloadManager = DownloadManager() + private var isGroupedBySeasons: Bool { return groupedEpisodes().count > 1 } @@ -329,6 +330,9 @@ struct MediaInfoView: View { refreshTrigger.toggle() Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General") + }, + onDownload: { + self.downloadEpisode(href: ep.href, episodeNumber: ep.number) } ) .id(refreshTrigger) @@ -379,6 +383,9 @@ struct MediaInfoView: View { refreshTrigger.toggle() Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General") + }, + onDownload: { + self.downloadEpisode(href: ep.href, episodeNumber: ep.number) } ) .id(refreshTrigger) @@ -1031,4 +1038,72 @@ struct MediaInfoView: View { findTopViewController.findViewController(rootVC).present(alert, animated: true) } } + + private func downloadEpisode(href: String, episodeNumber: Int) { + let downloadID = UUID() + activeFetchID = downloadID + currentStreamTitle = "Episode \(episodeNumber)" + showStreamLoadingView = true + + Task { + do { + let jsContent = try moduleManager.getModuleContent(module) + jsController.loadScript(jsContent) + + jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in + guard self.activeFetchID == downloadID else { return } + + if let streams = result.streams, !streams.isEmpty { + // If there are multiple streams, show selection dialog + if streams.count > 1 { + DispatchQueue.main.async { + self.showDownloadStreamSelection(streams: streams) + } + } else { + // If there's only one stream, download it directly + if let url = URL(string: streams[0]) { + DispatchQueue.main.async { + self.downloadManager.downloadAsset(from: url, module: self.module) + } + } + } + } + + DispatchQueue.main.async { + self.showStreamLoadingView = false + self.activeFetchID = nil + } + } + } catch { + DispatchQueue.main.async { + self.showStreamLoadingView = false + self.activeFetchID = nil + Logger.shared.log("Download error: \(error)", type: "Error") + } + } + } + } + + private func showDownloadStreamSelection(streams: [String]) { + let alert = UIAlertController(title: "Select Stream to Download", + message: "Choose a stream quality", + preferredStyle: .actionSheet) + + for (index, stream) in streams.enumerated() { + let action = UIAlertAction(title: "Stream \(index + 1)", style: .default) { _ in + if let url = URL(string: stream) { + self.downloadManager.downloadAsset(from: url, module: self.module) + } + } + alert.addAction(action) + } + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController { + findTopViewController.findViewController(rootVC).present(alert, animated: true) + } + } } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 4415b7b..2a8e32a 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -176,6 +176,7 @@ 131270152DC139CD0093AA9C /* DownloadManager */ = { isa = PBXGroup; children = ( + 131270162DC13A010093AA9C /* DownloadManager.swift */, ); path = DownloadManager; sourceTree = ""; @@ -374,7 +375,6 @@ children = ( 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */, 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */, - 131270162DC13A010093AA9C /* DownloadManager.swift */, ); path = ContinueWatching; sourceTree = ""; From ea8306b13b223f9425057402b5a1892cbfa246f9 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Thu, 1 May 2025 08:37:12 +0200 Subject: [PATCH 2/4] Revert "test download" This reverts commit 72fce2f14bc21c9c14187b7472e2d02e4eb606f3. --- Sora/ContentView.swift | 4 - .../ContinueWatching/DownloadManager.swift | 95 ++++++++ .../DownloadManager/DownloadManager.swift | 209 ------------------ Sora/Views/DownloadView.swift | 128 ++--------- .../EpisodeCell/EpisodeCell.swift | 12 +- Sora/Views/MediaInfoView/MediaInfoView.swift | 77 +------ Sulfur.xcodeproj/project.pbxproj | 2 +- 7 files changed, 124 insertions(+), 403 deletions(-) create mode 100644 Sora/Utils/ContinueWatching/DownloadManager.swift delete mode 100644 Sora/Utils/DownloadManager/DownloadManager.swift diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index cf1b64f..afbd4ea 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -15,10 +15,6 @@ struct ContentView: View { .tabItem { Label("Library", systemImage: "books.vertical") } - DownloadView() - .tabItem { - Label("Download", systemImage: "arrow.down.to.line.circle") - } SearchView() .tabItem { Label("Search", systemImage: "magnifyingglass") diff --git a/Sora/Utils/ContinueWatching/DownloadManager.swift b/Sora/Utils/ContinueWatching/DownloadManager.swift new file mode 100644 index 0000000..9c3d90d --- /dev/null +++ b/Sora/Utils/ContinueWatching/DownloadManager.swift @@ -0,0 +1,95 @@ +// +// DownloadManager.swift +// Sulfur +// +// Created by Francesco on 29/04/25. +// + +import SwiftUI +import AVKit +import AVFoundation + +class DownloadManager: NSObject, ObservableObject { + @Published var activeDownloads: [(URL, Double)] = [] + @Published var localPlaybackURL: URL? + + private var assetDownloadURLSession: AVAssetDownloadURLSession! + private var activeDownloadTasks: [URLSessionTask: URL] = [:] + + override init() { + super.init() + initializeDownloadSession() + loadLocalContent() + } + + private func initializeDownloadSession() { + let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader") + assetDownloadURLSession = AVAssetDownloadURLSession( + configuration: configuration, + assetDownloadDelegate: self, + delegateQueue: .main + ) + } + + func downloadAsset(from url: URL) { + let asset = AVURLAsset(url: url) + let task = assetDownloadURLSession.makeAssetDownloadTask( + asset: asset, + assetTitle: "Offline Video", + assetArtworkData: nil, + options: nil + ) + + task?.resume() + activeDownloadTasks[task!] = url + } + + private func loadLocalContent() { + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + do { + let contents = try FileManager.default.contentsOfDirectory( + at: documents, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) + + if let localURL = contents.first(where: { $0.pathExtension == "movpkg" }) { + localPlaybackURL = localURL + } + } catch { + print("Error loading local content: \(error)") + } + } +} + +extension DownloadManager: AVAssetDownloadDelegate { + func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { + activeDownloadTasks.removeValue(forKey: assetDownloadTask) + localPlaybackURL = location + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let error = error else { return } + print("Download error: \(error.localizedDescription)") + activeDownloadTasks.removeValue(forKey: task) + } + + func urlSession(_ session: URLSession, + assetDownloadTask: AVAssetDownloadTask, + didLoad timeRange: CMTimeRange, + totalTimeRangesLoaded loadedTimeRanges: [NSValue], + timeRangeExpectedToLoad: CMTimeRange) { + + guard let url = activeDownloadTasks[assetDownloadTask] else { return } + let progress = loadedTimeRanges + .map { $0.timeRangeValue.duration.seconds / timeRangeExpectedToLoad.duration.seconds } + .reduce(0, +) + + if let index = activeDownloads.firstIndex(where: { $0.0 == url }) { + activeDownloads[index].1 = progress + } else { + activeDownloads.append((url, progress)) + } + } +} diff --git a/Sora/Utils/DownloadManager/DownloadManager.swift b/Sora/Utils/DownloadManager/DownloadManager.swift deleted file mode 100644 index 7c975df..0000000 --- a/Sora/Utils/DownloadManager/DownloadManager.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// DownloadManager.swift -// Sulfur -// -// Created by Francesco on 29/04/25. -// - -import SwiftUI -import AVKit -import AVFoundation - -class DownloadManager: NSObject, ObservableObject { - @Published var activeDownloads: [ActiveDownload] = [] - @Published var savedAssets: [DownloadedAsset] = [] - - private var assetDownloadURLSession: AVAssetDownloadURLSession! - private var activeDownloadTasks: [URLSessionTask: URL] = [:] - - override init() { - super.init() - initializeDownloadSession() - loadSavedAssets() - reconcileFileSystemAssets() - } - - private func initializeDownloadSession() { - let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader-\(UUID().uuidString)") - assetDownloadURLSession = AVAssetDownloadURLSession( - configuration: configuration, - assetDownloadDelegate: self, - delegateQueue: .main - ) - } - - func downloadAsset(from url: URL, module: ScrapingModule) { - guard !savedAssets.contains(where: { $0.originalURL == url }) else { return } - - var urlRequest = URLRequest(url: url) - urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Origin") - urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Referer") - - let asset = AVURLAsset(url: urlRequest.url!, options: ["AVURLAssetHTTPHeaderFieldsKey": urlRequest.allHTTPHeaderFields ?? [:]]) - - let task = assetDownloadURLSession.makeAssetDownloadTask( - asset: asset, - assetTitle: url.lastPathComponent, - assetArtworkData: nil, - options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] - ) - - let download = ActiveDownload( - id: UUID(), - originalURL: url, - progress: 0, - task: task! - ) - - activeDownloads.append(download) - activeDownloadTasks[task!] = url - task?.resume() - } - - func deleteAsset(_ asset: DownloadedAsset) { - do { - try FileManager.default.removeItem(at: asset.localURL) - savedAssets.removeAll { $0.id == asset.id } - saveAssets() - } catch { - print("Error deleting asset: \(error)") - } - } - - func renameAsset(_ asset: DownloadedAsset, newName: String) { - guard let index = savedAssets.firstIndex(where: { $0.id == asset.id }) else { return } - savedAssets[index].name = newName - saveAssets() - } - - private func saveAssets() { - do { - let data = try JSONEncoder().encode(savedAssets) - UserDefaults.standard.set(data, forKey: "savedAssets") - } catch { - print("Error saving assets: \(error)") - } - } - - private func loadSavedAssets() { - guard let data = UserDefaults.standard.data(forKey: "savedAssets") else { return } - do { - savedAssets = try JSONDecoder().decode([DownloadedAsset].self, from: data) - } catch { - print("Error loading saved assets: \(error)") - } - } - - private func reconcileFileSystemAssets() { - guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - - do { - let fileURLs = try FileManager.default.contentsOfDirectory( - at: documents, - includingPropertiesForKeys: [.creationDateKey, .fileSizeKey], - options: .skipsHiddenFiles - ) - - for url in fileURLs where url.pathExtension == "movpkg" { - if !savedAssets.contains(where: { $0.localURL == url }) { - let newAsset = DownloadedAsset( - name: url.deletingPathExtension().lastPathComponent, - downloadDate: Date(), - originalURL: url, - localURL: url - ) - savedAssets.append(newAsset) - } - } - saveAssets() - } catch { - print("Error reconciling files: \(error)") - } - } -} - -extension DownloadManager: AVAssetDownloadDelegate { - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { - guard let originalURL = activeDownloadTasks[assetDownloadTask] else { return } - - let newAsset = DownloadedAsset( - name: originalURL.lastPathComponent, - downloadDate: Date(), - originalURL: originalURL, - localURL: location - ) - - savedAssets.append(newAsset) - saveAssets() - cleanupDownloadTask(assetDownloadTask) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let error = error else { return } - print("Download error: \(error.localizedDescription)") - cleanupDownloadTask(task) - } - - func urlSession(_ session: URLSession, - assetDownloadTask: AVAssetDownloadTask, - didLoad timeRange: CMTimeRange, - totalTimeRangesLoaded loadedTimeRanges: [NSValue], - timeRangeExpectedToLoad: CMTimeRange) { - guard let originalURL = activeDownloadTasks[assetDownloadTask], - let downloadIndex = activeDownloads.firstIndex(where: { $0.originalURL == originalURL }) else { return } - - let progress = loadedTimeRanges - .map { $0.timeRangeValue.duration.seconds / timeRangeExpectedToLoad.duration.seconds } - .reduce(0, +) - - activeDownloads[downloadIndex].progress = progress - } - - private func cleanupDownloadTask(_ task: URLSessionTask) { - activeDownloadTasks.removeValue(forKey: task) - activeDownloads.removeAll { $0.task == task } - } -} - -struct DownloadProgressView: View { - let download: ActiveDownload - - var body: some View { - VStack(alignment: .leading) { - Text(download.originalURL.lastPathComponent) - .font(.subheadline) - ProgressView(value: download.progress) - .progressViewStyle(LinearProgressViewStyle()) - Text("\(Int(download.progress * 100))%") - .font(.caption) - } - } -} - -struct AssetRowView: View { - let asset: DownloadedAsset - - var body: some View { - VStack(alignment: .leading) { - Text(asset.name) - .font(.headline) - Text("\(asset.fileSize ?? 0) bytes • \(asset.downloadDate.formatted())") - .font(.caption) - .foregroundColor(.secondary) - } - } -} - -struct ActiveDownload: Identifiable { - let id: UUID - let originalURL: URL - var progress: Double - let task: URLSessionTask -} - -extension URL { - static func isValidHLSURL(string: String) -> Bool { - guard let url = URL(string: string), url.pathExtension == "m3u8" else { return false } - return true - } -} diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 15ee58f..d98a28a 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -8,120 +8,40 @@ import SwiftUI import AVKit -struct DownloadedAsset: Identifiable, Codable { - let id: UUID - var name: String - let downloadDate: Date - let originalURL: URL - let localURL: URL - var fileSize: Int64? - - init(id: UUID = UUID(), name: String, downloadDate: Date, originalURL: URL, localURL: URL) { - self.id = id - self.name = name - self.downloadDate = downloadDate - self.originalURL = originalURL - self.localURL = localURL - self.fileSize = getFileSize() - } - - func getFileSize() -> Int64? { - do { - let values = try localURL.resourceValues(forKeys: [.fileSizeKey]) - return Int64(values.fileSize ?? 0) - } catch { - return nil - } - } -} - struct DownloadView: View { @StateObject private var viewModel = DownloadManager() - @State private var showingDeleteAlert = false - @State private var assetToDelete: DownloadedAsset? - @State private var renameText = "" - @State private var assetToRename: DownloadedAsset? + @State private var hlsURL = "https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8" var body: some View { NavigationView { - List { - if !viewModel.activeDownloads.isEmpty { - Section("Active Downloads") { - ForEach(viewModel.activeDownloads) { download in - DownloadProgressView(download: download) - } + VStack { + TextField("Enter HLS URL", text: $hlsURL) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + + Button("Download Stream") { + viewModel.downloadAsset(from: URL(string: hlsURL)!) + } + .padding() + + List(viewModel.activeDownloads, id: \.0) { (url, progress) in + VStack(alignment: .leading) { + Text(url.absoluteString) + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle()) } } - if !viewModel.savedAssets.isEmpty { - Section("Completed Downloads") { - ForEach(viewModel.savedAssets) { asset in - NavigationLink { - VideoPlayer(player: AVPlayer(url: asset.localURL)) - .navigationTitle(asset.name) - } label: { - AssetRowView(asset: asset) - } - .contextMenu { - Button(action: { startRenaming(asset) }) { - Label("Rename", systemImage: "pencil") - } - - Button(role: .destructive, action: { confirmDelete(asset) }) { - Label("Delete", systemImage: "trash") - } - } - } - } - } - - if viewModel.activeDownloads.isEmpty && viewModel.savedAssets.isEmpty { - Section { - Text("No downloads") - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .listRowBackground(Color.clear) + NavigationLink("Play Offline Content") { + if let url = viewModel.localPlaybackURL { + VideoPlayer(player: AVPlayer(url: url)) + } else { + Text("No offline content available") } } + .padding() } - .navigationTitle("Downloads") - .alert("Delete Download", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) { } - Button("Delete", role: .destructive) { - if let asset = assetToDelete { - viewModel.deleteAsset(asset) - } - } - } message: { - Text("Are you sure you want to delete \(assetToDelete?.name ?? "this download")?") - } - .alert("Rename Download", isPresented: Binding( - get: { assetToRename != nil }, - set: { if !$0 { assetToRename = nil } } - )) { - TextField("Name", text: $renameText) - Button("Cancel", role: .cancel) { - renameText = "" - assetToRename = nil - } - Button("Save") { - if let asset = assetToRename { - viewModel.renameAsset(asset, newName: renameText) - } - renameText = "" - assetToRename = nil - } - } + .navigationTitle("HLS Downloader") } } - - private func confirmDelete(_ asset: DownloadedAsset) { - assetToDelete = asset - showingDeleteAlert = true - } - - private func startRenaming(_ asset: DownloadedAsset) { - assetToRename = asset - renameText = asset.name - } -} \ No newline at end of file +} diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index a203ac2..bf41b8b 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -23,7 +23,6 @@ struct EpisodeCell: View { let onTap: (String) -> Void let onMarkAllPrevious: () -> Void - let onDownload: () -> Void @State private var episodeTitle: String = "" @State private var episodeImageUrl: String = "" @@ -36,12 +35,12 @@ struct EpisodeCell: View { var defaultBannerImage: String { let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light) return isLightMode - ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" - : "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" + ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" + : "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" } init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double, - itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void, onDownload: @escaping () -> Void) { // Add the new parameter + itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) { self.episodeIndex = episodeIndex self.episode = episode self.episodeID = episodeID @@ -49,7 +48,6 @@ struct EpisodeCell: View { self.itemID = itemID self.onTap = onTap self.onMarkAllPrevious = onMarkAllPrevious - self.onDownload = onDownload } var body: some View { @@ -101,10 +99,6 @@ struct EpisodeCell: View { Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill") } } - - Button(action: onDownload) { - Label("Download Episode", systemImage: "arrow.down.circle") - } } .onAppear { updateProgress() diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 6fc281d..6dec97e 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -59,8 +59,7 @@ struct MediaInfoView: View { @Environment(\.dismiss) private var dismiss @State private var orientationChanged: Bool = false - @StateObject private var downloadManager = DownloadManager() - + private var isGroupedBySeasons: Bool { return groupedEpisodes().count > 1 } @@ -330,9 +329,6 @@ struct MediaInfoView: View { refreshTrigger.toggle() Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General") - }, - onDownload: { - self.downloadEpisode(href: ep.href, episodeNumber: ep.number) } ) .id(refreshTrigger) @@ -383,9 +379,6 @@ struct MediaInfoView: View { refreshTrigger.toggle() Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General") - }, - onDownload: { - self.downloadEpisode(href: ep.href, episodeNumber: ep.number) } ) .id(refreshTrigger) @@ -1038,72 +1031,4 @@ struct MediaInfoView: View { findTopViewController.findViewController(rootVC).present(alert, animated: true) } } - - private func downloadEpisode(href: String, episodeNumber: Int) { - let downloadID = UUID() - activeFetchID = downloadID - currentStreamTitle = "Episode \(episodeNumber)" - showStreamLoadingView = true - - Task { - do { - let jsContent = try moduleManager.getModuleContent(module) - jsController.loadScript(jsContent) - - jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in - guard self.activeFetchID == downloadID else { return } - - if let streams = result.streams, !streams.isEmpty { - // If there are multiple streams, show selection dialog - if streams.count > 1 { - DispatchQueue.main.async { - self.showDownloadStreamSelection(streams: streams) - } - } else { - // If there's only one stream, download it directly - if let url = URL(string: streams[0]) { - DispatchQueue.main.async { - self.downloadManager.downloadAsset(from: url, module: self.module) - } - } - } - } - - DispatchQueue.main.async { - self.showStreamLoadingView = false - self.activeFetchID = nil - } - } - } catch { - DispatchQueue.main.async { - self.showStreamLoadingView = false - self.activeFetchID = nil - Logger.shared.log("Download error: \(error)", type: "Error") - } - } - } - } - - private func showDownloadStreamSelection(streams: [String]) { - let alert = UIAlertController(title: "Select Stream to Download", - message: "Choose a stream quality", - preferredStyle: .actionSheet) - - for (index, stream) in streams.enumerated() { - let action = UIAlertAction(title: "Stream \(index + 1)", style: .default) { _ in - if let url = URL(string: stream) { - self.downloadManager.downloadAsset(from: url, module: self.module) - } - } - alert.addAction(action) - } - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController { - findTopViewController.findViewController(rootVC).present(alert, animated: true) - } - } } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 2a8e32a..4415b7b 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -176,7 +176,6 @@ 131270152DC139CD0093AA9C /* DownloadManager */ = { isa = PBXGroup; children = ( - 131270162DC13A010093AA9C /* DownloadManager.swift */, ); path = DownloadManager; sourceTree = ""; @@ -375,6 +374,7 @@ children = ( 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */, 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */, + 131270162DC13A010093AA9C /* DownloadManager.swift */, ); path = ContinueWatching; sourceTree = ""; From f11d8706d695170b250c14e63b496299ea3ed497 Mon Sep 17 00:00:00 2001 From: cranci <100066266+cranci1@users.noreply.github.com> Date: Thu, 1 May 2025 08:44:56 +0200 Subject: [PATCH 3/4] Real branch for #90 (#122) * Add App Store Link * Add App Store icon * Add Testflight link and icon * Fixed icon sizing * typo * fixed readme --------- Co-authored-by: Storm --- .github/app-store-badge.png | Bin 0 -> 4714 bytes README.md | 8 ++++++++ 2 files changed, 8 insertions(+) create mode 100644 .github/app-store-badge.png diff --git a/.github/app-store-badge.png b/.github/app-store-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..8e91eb3bd06fdf9100d62f7fc6295b6e58565d0c GIT binary patch literal 4714 zcmV-w5|!Px#eo#zQMMrQ(deWk^Ea0 z#(j*pBkZB__ALwJKE}7c0@tNzdDqjOjNg5l_MB6+`%2TE-#ujH_l5kSL;fd=;$Fu8 zI^-|8rE*8(wX477F4~$f=~Z2l4_}8To}HZ*h@|d^is^Y%uN_`DG&~nu4Mv@rfLOeT~h;9asE)uyxoTVc_wB1CF6(x*^ z(9YLuS61hU?vEbsbWT}0kk@iTP4U%-OyLvqBM)+QmN4{o`WzE~)Pm&EQh>w?h7*m` ziI7)VN8ap!{J_+s8nbix?SO?VPXZ>1b*Bmy2inM)9wt)PhX?CJTsx67$6D{6rBNSO z%g9^XD-zf3Tn=((#M*QZO-l#KAf)I9o`XurDhp4QeMMt&?ycqXULJt`*~rkom#F4EFDOJky<9e5fc`0K2;BuA>)q2 z;hTPRB|jbRk(l=WGLV-B@@g042dM>E3*P156Ujyej7T1*>4kFj3OUy@Kq3D=25DTo zN+p%N(o6$zC^@tKhRPOGX9i;-$J&*fX(?kZRS=hj+*ERY*cUm{V|OwU5~K1Q{|oS- z1RecWp4w{DR3>t)X$^HPQb~mAsUmhD*RMpSLPPB|=n8HuFFoF-9chlXF>`_7m<{%L|z#ZWDct-&oS16u#qEcs&-D)8RuKbt)sn1 zTm*7zD0#a#ax+lI_BhcmGSVmUn;s9>sYAWvH6^Goq z2AkKPFA901-`VE!)Ev1~AoB|;tg?5UptAt9H>j05ZVhsUgp_j;Bg1MT`|*H#svVFQ z7mZw`J91Bwh6BiXFDTYq<+5{zL>b{t$ZK1~8Tqm+Pe>)^$jixvyA69!7#)UysqPNt z3ePffW*PS0s+0gBMCCbWH0>DuJ$#gv^!_W8@YlEO`?S!qk(qt;%!H}R8iiR z@QrIx;>Pf;+#bF;4MltD$Z=YiY1URz!M`C^pi=6-XQ8rmq#2noa(9xZQ^0zX#`WiP zC22(GPj^BNlDyNUW$R!->txu;?Q zIg-sXDx{inS>OaJT%xiSHXP|Lxb~-MQ!OL!mn#h+A8K;LO~AAwdouE>1s!9L+a5Ao zC;+K8ATQi4*s(cbG)YG7AJu=TwAYcy$Kaeny%#d{vLFz6=*b}MNZfZkT-CR+^v=u% z$|CY{iI&KZd~6=n047wicR)_GmLzWj-}X%@l!9E_660u|N=w^lRsJ>>xp6UOea32q zOlzK!Rn5~N@&lgt%k86$GIq*_-vs28ϝuCB@l}j?R5jmbM7x^V_W~!q1JPy&w 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index b2eb745..370d33e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ An iOS and macOS modular web scraping app, under the GPLv3.0 License. + + ## Table of Contents From 1ec726913f733f807de11723822f51ae8d6d98c8 Mon Sep 17 00:00:00 2001 From: cranci <100066266+cranci1@users.noreply.github.com> Date: Thu, 1 May 2025 08:56:25 +0200 Subject: [PATCH 4/4] Update README.md --- README.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 370d33e..cbbe548 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,14 @@ [![Build and Release IPA](https://github.com/cranci1/Sora/actions/workflows/build.yml/badge.svg)](https://github.com/cranci1/Sora/actions/workflows/build.yml) [![Discord](https://img.shields.io/discord/1293430817841741899.svg?logo=discord&color=blue)](https://discord.gg/XR3SrmUbpd) [![Platform](https://img.shields.io/badge/Platform-iOS%20%7C%20iPadOS%2015.0%2B%20%26%20macOS%2012.0%2B-red?logo=apple&logoColor=white)](https://img.shields.io/badge/Platform-iOS%20%7C%20iPadOS%2015.0%2B%20%26%20macOS%2012.0%2B-red?logo=apple&logoColor=white) -An iOS and macOS modular web scraping app, under the GPLv3.0 License. - - +**An iOS and macOS modular web scraping app, under the GPLv3.0 License.** ## Table of Contents - [Features](#features) +- [Installation](#installation) - [Frequently Asked Questions](#frequently-asked-questions) - [Acknowledgements](#acknowledgements) - [License](#license) @@ -38,6 +31,20 @@ An iOS and macOS modular web scraping app, under the GPLv3.0 License. - [x] External Media players (VLC, infuse, Outplayer, nPlayer) - [x] Background playback and Picture-in-Picture (PiP) support +## Installation + +You can download Sora on the App Store for stable updates or on Testflight for more updates but maybe some instability. (Testflight is recommended): + + + Build and Release IPA + + + + Build and Release IPA + + +Additionaly you can install the app using xCode or using the .ipa file that you can find on the [Releases](https://github.com/cranci1/Sora/releases) tab or on the [nightly](https://nightly.link/cranci1/Sora/workflows/build/dev/Sulfur-IPA.zip) build page. + ## Frequently Asked Questions 1. **What is Sora?**