1+ //
2+ // AudioFileImportManager.swift
3+ // VibeScribe
4+ //
5+ // Created by System on 13.04.2025.
6+ //
7+
8+ import Foundation
9+ import SwiftData
10+ import AVFoundation
11+
12+ // MARK: - Import Errors
13+
14+ enum AudioFileImportError : LocalizedError {
15+ case fileNotFound( String )
16+ case unsupportedFormat( String )
17+ case invalidAudioFile( String )
18+ case conversionFailed( String )
19+ case saveFailed( String )
20+
21+ var errorDescription : String ? {
22+ switch self {
23+ case . fileNotFound( let message) :
24+ return " File not found: \( message) "
25+ case . unsupportedFormat( let message) :
26+ return " Unsupported format: \( message) "
27+ case . invalidAudioFile( let message) :
28+ return " Invalid audio file: \( message) "
29+ case . conversionFailed( let message) :
30+ return " Conversion failed: \( message) "
31+ case . saveFailed( let message) :
32+ return " Save failed: \( message) "
33+ }
34+ }
35+ }
36+
37+ @MainActor
38+ class AudioFileImportManager : ObservableObject {
39+ @Published var isImporting = false
40+ @Published var importProgress : String = " "
41+ @Published var error : Error ? = nil
42+
43+ // Private task for cancellation support
44+ private var importTask : Task < Void , Never > ?
45+
46+ // Supported audio file types - made private and with computed public access
47+ private static let audioFileExtensions : Set < String > = [
48+ " mp3 " , " wav " , " m4a " , " aac " , " ogg " , " flac " , " wma " , " mp4 " , " mov "
49+ ]
50+
51+ private static let audioUTTypes : Set < String > = [
52+ " public.mp3 " , " public.audio " , " public.audiovisual-content " ,
53+ " com.microsoft.waveform-audio " , " public.aiff-audio " ,
54+ " public.mpeg-4-audio " , " public.ac3-audio "
55+ ]
56+
57+ /// Supported audio file extensions
58+ static var supportedAudioTypes : [ String ] {
59+ Array ( audioFileExtensions) . sorted ( )
60+ }
61+
62+ /// Supported UTI types for audio files
63+ static var supportedUTTypes : [ String ] {
64+ Array ( audioUTTypes) . sorted ( )
65+ }
66+
67+ deinit {
68+ importTask? . cancel ( )
69+ }
70+
71+ /// Imports audio files from drag-and-drop
72+ /// - Parameters:
73+ /// - urls: Array of URLs to import
74+ /// - modelContext: SwiftData model context for saving records
75+ func importAudioFiles( urls: [ URL ] , modelContext: ModelContext ) {
76+ guard !isImporting else {
77+ Logger . warning ( " Import already in progress, ignoring new request " , category: . audio)
78+ return
79+ }
80+
81+ // Cancel any existing import task
82+ cancelImport ( )
83+
84+ isImporting = true
85+ error = nil
86+
87+ Logger . info ( " Starting import of \( urls. count) audio files " , category: . audio)
88+
89+ importTask = Task {
90+ var successCount = 0
91+ var failureCount = 0
92+
93+ for (index, url) in urls. enumerated ( ) {
94+ // Check for cancellation
95+ guard !Task. isCancelled else {
96+ Logger . info ( " Import cancelled by user " , category: . audio)
97+ break
98+ }
99+
100+ do {
101+ try await importSingleFile ( url: url, index: index + 1 , total: urls. count, modelContext: modelContext)
102+ successCount += 1
103+ } catch {
104+ failureCount += 1
105+ Logger . error ( " Failed to import file: \( url. lastPathComponent) " , error: error, category: . audio)
106+ await MainActor . run {
107+ self . error = error
108+ }
109+ }
110+ }
111+
112+ await MainActor . run {
113+ self . isImporting = false
114+ self . importProgress = " "
115+ let totalProcessed = successCount + failureCount
116+ Logger . info ( " Import completed: \( successCount) successful, \( failureCount) failed out of \( totalProcessed) files " , category: . audio)
117+ }
118+ }
119+ }
120+
121+ /// Cancels the current import operation
122+ func cancelImport( ) {
123+ importTask? . cancel ( )
124+ importTask = nil
125+
126+ if isImporting {
127+ isImporting = false
128+ importProgress = " "
129+ Logger . info ( " Import operation cancelled " , category: . audio)
130+ }
131+ }
132+
133+ private func importSingleFile( url: URL , index: Int , total: Int , modelContext: ModelContext ) async throws {
134+ await MainActor . run {
135+ importProgress = " Importing \( index) / \( total) : \( url. lastPathComponent) "
136+ }
137+
138+ Logger . info ( " Importing file \( index) / \( total) : \( url. lastPathComponent) " , category: . audio)
139+
140+ // Validate file exists and is accessible
141+ guard FileManager . default. fileExists ( atPath: url. path) else {
142+ throw AudioFileImportError . fileNotFound ( " File not found: \( url. lastPathComponent) " )
143+ }
144+
145+ // Check if file is actually an audio file
146+ guard isAudioFile ( url: url) else {
147+ throw AudioFileImportError . unsupportedFormat ( " Unsupported file format: \( url. pathExtension) " )
148+ }
149+
150+ // Validate it's actually a valid audio file
151+ guard await AudioUtils . isValidAudioFile ( url: url) else {
152+ throw AudioFileImportError . invalidAudioFile ( " File does not contain valid audio: \( url. lastPathComponent) " )
153+ }
154+
155+ // Get original filename with extension
156+ let originalName = url. lastPathComponent
157+
158+ // Convert to standard format
159+ await MainActor . run {
160+ importProgress = " Converting \( index) / \( total) : \( originalName) "
161+ }
162+
163+ let convertedURL = try await convertToStandardFormat ( url: url)
164+
165+ // Get duration
166+ let duration = AudioUtils . getAudioDuration ( url: convertedURL)
167+
168+ // Validate duration is reasonable
169+ guard duration > 0 else {
170+ throw AudioFileImportError . invalidAudioFile ( " Audio file has invalid duration: \( originalName) " )
171+ }
172+
173+ // Create record
174+ let record = Record (
175+ name: originalName,
176+ fileURL: convertedURL,
177+ duration: duration,
178+ includesSystemAudio: false // Imported files don't include system audio
179+ )
180+
181+ try await MainActor . run {
182+ importProgress = " Saving \( index) / \( total) : \( originalName) "
183+
184+ modelContext. insert ( record)
185+
186+ do {
187+ try modelContext. save ( )
188+ Logger . info ( " Successfully imported and saved record: \( originalName) " , category: . audio)
189+
190+ // Notify about new record for UI updates
191+ NotificationCenter . default. post (
192+ name: NSNotification . Name ( " NewRecordCreated " ) ,
193+ object: nil ,
194+ userInfo: [ " recordId " : record. id]
195+ )
196+
197+ // The processing pipeline will start automatically when the record is viewed
198+ // thanks to the logic in RecordDetailView's onAppear and onReceive handlers
199+
200+ } catch {
201+ Logger . error ( " Failed to save imported record: \( originalName) " , error: error, category: . audio)
202+ throw AudioFileImportError . saveFailed ( " Failed to save record: \( error. localizedDescription) " )
203+ }
204+ }
205+ }
206+
207+ private func convertToStandardFormat( url: URL ) async throws -> URL {
208+ return try await withCheckedThrowingContinuation { continuation in
209+ AudioUtils . convertAudioToStandardFormat ( inputURL: url) { result in
210+ switch result {
211+ case . success( let convertedURL) :
212+ continuation. resume ( returning: convertedURL)
213+ case . failure( let error) :
214+ continuation. resume ( throwing: error)
215+ }
216+ }
217+ }
218+ }
219+
220+ private func isAudioFile( url: URL ) -> Bool {
221+ let fileExtension = url. pathExtension. lowercased ( )
222+ return Self . audioFileExtensions. contains ( fileExtension)
223+ }
224+
225+
226+ }
227+
228+ // MARK: - Drag and Drop Support
229+
230+ extension AudioFileImportManager {
231+ /// Checks if any of the provided URLs are supported audio files
232+ static func containsSupportedAudioFiles( urls: [ URL ] ) -> Bool {
233+ return urls. contains { url in
234+ let fileExtension = url. pathExtension. lowercased ( )
235+ return audioFileExtensions. contains ( fileExtension)
236+ }
237+ }
238+
239+ /// Filters URLs to only include supported audio files
240+ static func filterSupportedAudioFiles( urls: [ URL ] ) -> [ URL ] {
241+ return urls. filter { url in
242+ let fileExtension = url. pathExtension. lowercased ( )
243+ return audioFileExtensions. contains ( fileExtension)
244+ }
245+ }
246+
247+ /// Validates multiple files concurrently
248+ static func validateAudioFiles( urls: [ URL ] ) async -> [ URL : Bool ] {
249+ await withTaskGroup ( of: ( URL, Bool) . self, returning: [ URL : Bool ] . self) { group in
250+ for url in urls {
251+ group. addTask {
252+ let isValid = await AudioUtils . isValidAudioFile ( url: url)
253+ return ( url, isValid)
254+ }
255+ }
256+
257+ var results : [ URL : Bool ] = [ : ]
258+ for await (url, isValid) in group {
259+ results [ url] = isValid
260+ }
261+ return results
262+ }
263+ }
264+ }
0 commit comments