import Foundation import SwiftData /// A utility class that manages automatic cleanup of audio files while preserving transcript data class AudioCleanupManager { static let shared = AudioCleanupManager() private var cleanupTimer: Timer? // Default cleanup settings private let defaultRetentionDays = 7 private let cleanupCheckInterval: TimeInterval = 86400 // Check once per day (in seconds) private init() {} /// Start the automatic cleanup process func startAutomaticCleanup(modelContext: ModelContext) { // Cancel any existing timer cleanupTimer?.invalidate() // Perform initial cleanup Task { await performCleanup(modelContext: modelContext) } // Schedule regular cleanup cleanupTimer = Timer.scheduledTimer(withTimeInterval: cleanupCheckInterval, repeats: true) { [weak self] _ in Task { [weak self] in await self?.performCleanup(modelContext: modelContext) } } } /// Stop the automatic cleanup process func stopAutomaticCleanup() { cleanupTimer?.invalidate() cleanupTimer = nil } /// Get information about the files that would be cleaned up func getCleanupInfo(modelContext: ModelContext) async -> (fileCount: Int, totalSize: Int64, transcriptions: [Transcription]) { // Get retention period from UserDefaults let retentionDays = UserDefaults.standard.integer(forKey: "AudioRetentionPeriod") let effectiveRetentionDays = retentionDays > 0 ? retentionDays : defaultRetentionDays // Calculate the cutoff date let calendar = Calendar.current guard let cutoffDate = calendar.date(byAdding: .day, value: -effectiveRetentionDays, to: Date()) else { return (0, 0, []) } do { // Execute SwiftData operations on the main thread return try await MainActor.run { // Create a predicate to find transcriptions with audio files older than the cutoff date let descriptor = FetchDescriptor( predicate: #Predicate { transcription in transcription.timestamp < cutoffDate && transcription.audioFileURL != nil } ) let transcriptions = try modelContext.fetch(descriptor) // Calculate stats (can be done on any thread) var fileCount = 0 var totalSize: Int64 = 0 var eligibleTranscriptions: [Transcription] = [] for transcription in transcriptions { if let urlString = transcription.audioFileURL, let url = URL(string: urlString), FileManager.default.fileExists(atPath: url.path) { if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), let fileSize = attributes[.size] as? Int64 { totalSize += fileSize fileCount += 1 eligibleTranscriptions.append(transcription) } } } return (fileCount, totalSize, eligibleTranscriptions) } } catch { return (0, 0, []) } } /// Perform the cleanup operation private func performCleanup(modelContext: ModelContext) async { // Get retention period from UserDefaults let retentionDays = UserDefaults.standard.integer(forKey: "AudioRetentionPeriod") let effectiveRetentionDays = retentionDays > 0 ? retentionDays : defaultRetentionDays // Check if automatic cleanup is enabled let isCleanupEnabled = UserDefaults.standard.bool(forKey: "IsAudioCleanupEnabled") guard isCleanupEnabled else { return } // Calculate the cutoff date let calendar = Calendar.current guard let cutoffDate = calendar.date(byAdding: .day, value: -effectiveRetentionDays, to: Date()) else { return } do { // Execute SwiftData operations on the main thread try await MainActor.run { // Create a predicate to find transcriptions with audio files older than the cutoff date let descriptor = FetchDescriptor( predicate: #Predicate { transcription in transcription.timestamp < cutoffDate && transcription.audioFileURL != nil } ) let transcriptions = try modelContext.fetch(descriptor) var deletedCount = 0 for transcription in transcriptions { if let urlString = transcription.audioFileURL, let url = URL(string: urlString), FileManager.default.fileExists(atPath: url.path) { do { try FileManager.default.removeItem(at: url) transcription.audioFileURL = nil deletedCount += 1 } catch { // Skip this file - don't update audioFileURL if deletion failed } } } if deletedCount > 0 { try modelContext.save() } } } catch { // Silently fail - cleanup is non-critical } } /// Run cleanup manually - can be called from settings func runManualCleanup(modelContext: ModelContext) async { await performCleanup(modelContext: modelContext) } /// Run cleanup on the specified transcriptions func runCleanupForTranscriptions(modelContext: ModelContext, transcriptions: [Transcription]) async -> (deletedCount: Int, errorCount: Int) { do { // Execute SwiftData operations on the main thread return try await MainActor.run { var deletedCount = 0 var errorCount = 0 for transcription in transcriptions { if let urlString = transcription.audioFileURL, let url = URL(string: urlString), FileManager.default.fileExists(atPath: url.path) { do { try FileManager.default.removeItem(at: url) transcription.audioFileURL = nil deletedCount += 1 } catch { errorCount += 1 } } } if deletedCount > 0 || errorCount > 0 { try? modelContext.save() } return (deletedCount, errorCount) } } catch { return (0, 0) } } /// Format file size in human-readable form func formatFileSize(_ size: Int64) -> String { let byteCountFormatter = ByteCountFormatter() byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB] byteCountFormatter.countStyle = .file return byteCountFormatter.string(fromByteCount: size) } }