363 lines
16 KiB
Swift
363 lines
16 KiB
Swift
|
|
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<String> = []
|
||
|
|
private var totalUploads = 0
|
||
|
|
private var completedUploads = 0
|
||
|
|
|
||
|
|
private static let backgroundSessionId = "com.navidromeplayer.batchupload"
|
||
|
|
|
||
|
|
static let audioExtensions: Set<String> = [
|
||
|
|
"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 = ""
|
||
|
|
}
|
||
|
|
}
|