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 = "";