import Foundation import UIKit import Combine import UniformTypeIdentifiers // MARK: - Zip Import Manager class ZipImportManager: NSObject, ObservableObject, URLSessionDataDelegate, URLSessionTaskDelegate { static let shared = ZipImportManager() // MARK: - Published State @Published var isExtracting = false @Published var extractionProgress: String = "" @Published var extractedFiles: [ExtractedAudioFile] = [] @Published var uploadStates: [String: UploadFileState] = [:] // filename -> state @Published var isUploading = false @Published var uploadProgress: Double = 0 // 0..1 struct ExtractedAudioFile: Identifiable { let id = UUID() let url: URL let filename: String let fileSize: Int64 } enum UploadFileState: Equatable { case pending case uploading(progress: Double) case completed case failed(String) } // MARK: - Internal private let api = CompanionAPIService() private let fileManager = FileManager.default private var extractDir: URL? private var backgroundSession: URLSession! private var backgroundCompletionHandler: (() -> Void)? private var activeUploads: [Int: String] = [:] // taskId -> filename private var keepOfflineFiles: Set = [] private var totalUploads = 0 private var completedUploads = 0 private static let backgroundSessionId = "com.navidromeplayer.batchupload" static let audioExtensions: Set = [ "mp3", "flac", "m4a", "aac", "ogg", "opus", "wav", "wma", "alac", "aiff", "ape", "wv" ] private override init() { super.init() let config = URLSessionConfiguration.background(withIdentifier: Self.backgroundSessionId) config.isDiscretionary = false config.sessionSendsLaunchEvents = true backgroundSession = URLSession(configuration: config, delegate: self, delegateQueue: .main) } /// Called by AppDelegate when background session completes func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) { backgroundCompletionHandler = handler } // MARK: - Extract Zip func extractZip(at sourceURL: URL) { isExtracting = true extractionProgress = "Preparing..." extractedFiles = [] uploadStates = [:] Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } do { // Gain access to the security-scoped resource let accessing = sourceURL.startAccessingSecurityScopedResource() defer { if accessing { sourceURL.stopAccessingSecurityScopedResource() } } // Create extraction directory let tempDir = FileManager.default.temporaryDirectory let extractDir = tempDir.appendingPathComponent("zip_extract_\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) await MainActor.run { self.extractDir = extractDir self.extractionProgress = "Extracting..." } // Copy zip to temp (fileImporter URLs may be transient) let tempZip = tempDir.appendingPathComponent("import_\(UUID().uuidString).zip") try FileManager.default.copyItem(at: sourceURL, to: tempZip) // Use built-in Archive/unzip via Process or NSFileCoordinator // iOS doesn't have /usr/bin/unzip — use Apple's compression framework try self.unzipFile(at: tempZip, to: extractDir) // Clean up zip try? FileManager.default.removeItem(at: tempZip) // Find audio files recursively let enumerator = FileManager.default.enumerator(at: extractDir, includingPropertiesForKeys: [.fileSizeKey]) var audioFiles: [ExtractedAudioFile] = [] while let fileURL = enumerator?.nextObject() as? URL { let ext = fileURL.pathExtension.lowercased() guard Self.audioExtensions.contains(ext) else { continue } // Skip macOS resource fork junk guard !fileURL.lastPathComponent.hasPrefix("._") else { continue } guard !fileURL.path.contains("__MACOSX") else { continue } let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey]) let size = Int64(attrs?.fileSize ?? 0) audioFiles.append(ExtractedAudioFile( url: fileURL, filename: fileURL.lastPathComponent, fileSize: size )) } audioFiles.sort { $0.filename.localizedCaseInsensitiveCompare($1.filename) == .orderedAscending } let finalFiles = audioFiles await MainActor.run { self.extractedFiles = finalFiles self.isExtracting = false self.extractionProgress = finalFiles.isEmpty ? "No audio files found" : "\(finalFiles.count) tracks found" // Initialize upload states for file in finalFiles { self.uploadStates[file.filename] = .pending } } if finalFiles.isEmpty { try? FileManager.default.removeItem(at: extractDir) } } catch { await MainActor.run { self.isExtracting = false self.extractionProgress = "Extraction failed: \(error.localizedDescription)" } DebugLogger.shared.log("Zip extract failed: \(error.localizedDescription)", category: "Upload") } } } // MARK: - Zip Extraction (Foundation-based) /// Extracts a zip file using FileManager's built-in support (iOS 16+) /// Falls back to manual extraction via compression framework private func unzipFile(at zipURL: URL, to destDir: URL) throws { // Use Process/unzip if available, otherwise use Foundation // On iOS, we'll use a simple approach: copy zip and use FileManager // Actually, iOS doesn't have native unzip API — we need to use // the compress/decompress approach or bundle a library. // For simplicity, use the fact that .zip can be opened as a read archive. // Use Apple's built-in support via spawning an extraction coordinator let coordinator = NSFileCoordinator() var coordError: NSError? var extractError: Error? coordinator.coordinate(readingItemAt: zipURL, options: [.forUploading], error: &coordError) { url in // The system gives us a temp URL we can work with do { // Try to move/copy the contents let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) for item in contents { let dest = destDir.appendingPathComponent(item.lastPathComponent) try FileManager.default.copyItem(at: item, to: dest) } } catch { extractError = error } } if let error = coordError ?? extractError { // Fallback: try treating the zip as a directory (works on some iOS versions) // If that fails too, try a manual byte-level approach let zipContents = try? FileManager.default.contentsOfDirectory(at: zipURL, includingPropertiesForKeys: nil) if let contents = zipContents, !contents.isEmpty { for item in contents { let dest = destDir.appendingPathComponent(item.lastPathComponent) try FileManager.default.copyItem(at: item, to: dest) } } else { throw CompanionError.extractionFailed(error.localizedDescription) } } } // MARK: - Batch Upload func startBatchUpload( metadata: UploadMetadata, keepOffline: Bool ) { guard !extractedFiles.isEmpty else { return } isUploading = true uploadProgress = 0 totalUploads = extractedFiles.count completedUploads = 0 keepOfflineFiles = keepOffline ? Set(extractedFiles.map { $0.filename }) : [] DebugLogger.shared.log("Starting batch upload: \(totalUploads) files, keepOffline=\(keepOffline)", category: "Upload") for (index, file) in extractedFiles.enumerated() { do { // Per-file metadata with track number var fileMeta = metadata fileMeta.trackNumber = "\(index + 1)" // Use filename (without extension) as title if title is empty if fileMeta.title.isEmpty { fileMeta.title = file.url.deletingPathExtension().lastPathComponent } let boundary = "Boundary-\(UUID().uuidString)" let payloadURL = try api.buildMultipartPayloadFile( fileURL: file.url, metadata: fileMeta, boundary: boundary ) let uploadURL = try api.uploadURL() var request = URLRequest(url: uploadURL) request.httpMethod = "POST" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") let task = backgroundSession.uploadTask(with: request, fromFile: payloadURL) activeUploads[task.taskIdentifier] = file.filename uploadStates[file.filename] = .uploading(progress: 0) task.resume() DebugLogger.shared.log("Queued upload: \(file.filename) (task \(task.taskIdentifier))", category: "Upload") } catch { uploadStates[file.filename] = .failed(error.localizedDescription) completedUploads += 1 DebugLogger.shared.log("Upload prep failed: \(file.filename) — \(error.localizedDescription)", category: "Upload") } } } // MARK: - URLSessionTaskDelegate func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { guard let filename = activeUploads[task.taskIdentifier] else { return } let progress = totalBytesExpectedToSend > 0 ? Double(totalBytesSent) / Double(totalBytesExpectedToSend) : 0 uploadStates[filename] = .uploading(progress: progress) } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let filename = activeUploads.removeValue(forKey: task.taskIdentifier) else { return } if let error = error { uploadStates[filename] = .failed(error.localizedDescription) DebugLogger.shared.log("Upload failed: \(filename) — \(error.localizedDescription)", category: "Upload") } else if let httpResponse = task.response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) { uploadStates[filename] = .completed DebugLogger.shared.log("Upload completed: \(filename)", category: "Upload") // Post-upload: keep offline or delete handlePostUpload(filename: filename) } else { let code = (task.response as? HTTPURLResponse)?.statusCode ?? 0 uploadStates[filename] = .failed("HTTP \(code)") DebugLogger.shared.log("Upload failed: \(filename) — HTTP \(code)", category: "Upload") } completedUploads += 1 uploadProgress = totalUploads > 0 ? Double(completedUploads) / Double(totalUploads) : 1 if completedUploads >= totalUploads { isUploading = false cleanupTempFiles() DebugLogger.shared.log("Batch upload complete", category: "Upload") } } func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { DebugLogger.shared.log("Background session invalidated: \(error?.localizedDescription ?? "nil")", category: "Upload") } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { DebugLogger.shared.log("Background session finished all events", category: "Upload") backgroundCompletionHandler?() backgroundCompletionHandler = nil } // MARK: - Post-Upload Logic private func handlePostUpload(filename: String) { guard let file = extractedFiles.first(where: { $0.filename == filename }) else { return } if keepOfflineFiles.contains(filename) { // Move to Documents for offline playback let offlineDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! .appendingPathComponent("OfflineMusic", isDirectory: true) try? fileManager.createDirectory(at: offlineDir, withIntermediateDirectories: true) let dest = offlineDir.appendingPathComponent(filename) try? fileManager.moveItem(at: file.url, to: dest) DebugLogger.shared.log("Kept offline: \(filename)", category: "Upload") } else { // Delete extracted file — server is source of truth try? fileManager.removeItem(at: file.url) } // Always clean up the multipart payload temp file cleanupPayloadFile(for: filename) } private func cleanupPayloadFile(for filename: String) { let tempDir = fileManager.temporaryDirectory if let files = try? fileManager.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil) { for file in files where file.lastPathComponent.hasPrefix("upload_") && file.pathExtension == "multipart" { try? fileManager.removeItem(at: file) } } } // MARK: - Cleanup private func cleanupTempFiles() { // Clean extraction directory if let dir = extractDir { try? fileManager.removeItem(at: dir) extractDir = nil } // Clean any leftover multipart payloads let tempDir = fileManager.temporaryDirectory if let files = try? fileManager.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil) { for file in files { if file.lastPathComponent.hasPrefix("upload_") || file.lastPathComponent.hasPrefix("zip_extract_") { try? fileManager.removeItem(at: file) } } } } func cancelAll() { backgroundSession.getTasksWithCompletionHandler { _, uploads, _ in for task in uploads { task.cancel() } } activeUploads.removeAll() isUploading = false cleanupTempFiles() } func reset() { cancelAll() extractedFiles = [] uploadStates = [:] uploadProgress = 0 extractionProgress = "" } }