NavidromeApp/iOS/Views/Companion/ZipImportManager.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

362 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 = ""
}
}