Added support for removing recordings older than x days.

This commit is contained in:
Beingpax 2025-03-12 14:11:39 +05:45
parent 37d1914e38
commit 19142522bd
4 changed files with 393 additions and 0 deletions

View File

@ -0,0 +1,241 @@
import Foundation
import SwiftData
import OSLog
/// A utility class that manages automatic cleanup of audio files while preserving transcript data
class AudioCleanupManager {
static let shared = AudioCleanupManager()
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "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() {
logger.info("AudioCleanupManager initialized")
}
/// Start the automatic cleanup process
func startAutomaticCleanup(modelContext: ModelContext) {
logger.info("Starting automatic audio cleanup")
// 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)
}
}
logger.info("Automatic cleanup scheduled")
}
/// Stop the automatic cleanup process
func stopAutomaticCleanup() {
logger.info("Stopping automatic audio cleanup")
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]) {
logger.info("Analyzing potential audio cleanup")
// 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 {
logger.error("Failed to calculate cutoff date")
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<Transcription>(
predicate: #Predicate<Transcription> { 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) {
do {
// Get file attributes to determine size
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
if let fileSize = attributes[.size] as? Int64 {
totalSize += fileSize
fileCount += 1
eligibleTranscriptions.append(transcription)
}
} catch {
self.logger.error("Failed to get attributes for \(url.lastPathComponent): \(error.localizedDescription)")
}
}
}
self.logger.info("Found \(fileCount) files eligible for cleanup, totaling \(self.formatFileSize(totalSize))")
return (fileCount, totalSize, eligibleTranscriptions)
}
} catch {
logger.error("Error analyzing files for cleanup: \(error.localizedDescription)")
return (0, 0, [])
}
}
/// Perform the cleanup operation
private func performCleanup(modelContext: ModelContext) async {
logger.info("Performing audio cleanup")
// 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 {
logger.info("Audio cleanup is disabled, skipping")
return
}
logger.info("Audio retention period: \(effectiveRetentionDays) days")
// Calculate the cutoff date
let calendar = Calendar.current
guard let cutoffDate = calendar.date(byAdding: .day, value: -effectiveRetentionDays, to: Date()) else {
logger.error("Failed to calculate cutoff date")
return
}
logger.info("Cutoff date for audio cleanup: \(cutoffDate)")
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<Transcription>(
predicate: #Predicate<Transcription> { transcription in
transcription.timestamp < cutoffDate &&
transcription.audioFileURL != nil
}
)
let transcriptions = try modelContext.fetch(descriptor)
self.logger.info("Found \(transcriptions.count) transcriptions with audio files to clean up")
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 {
// Delete the audio file
try FileManager.default.removeItem(at: url)
// Update the transcription to remove the audio file reference
transcription.audioFileURL = nil
deletedCount += 1
self.logger.debug("Deleted audio file: \(url.lastPathComponent)")
} catch {
errorCount += 1
self.logger.error("Failed to delete audio file \(url.lastPathComponent): \(error.localizedDescription)")
}
}
}
if deletedCount > 0 || errorCount > 0 {
try modelContext.save()
self.logger.info("Cleanup complete. Deleted \(deletedCount) files. Failed: \(errorCount)")
}
}
} catch {
logger.error("Error during audio cleanup: \(error.localizedDescription)")
}
}
/// 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) {
logger.info("Running cleanup for \(transcriptions.count) specific transcriptions")
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 {
// Delete the audio file
try FileManager.default.removeItem(at: url)
// Update the transcription to remove the audio file reference
transcription.audioFileURL = nil
deletedCount += 1
self.logger.debug("Deleted audio file: \(url.lastPathComponent)")
} catch {
errorCount += 1
self.logger.error("Failed to delete audio file \(url.lastPathComponent): \(error.localizedDescription)")
}
}
}
if deletedCount > 0 || errorCount > 0 {
do {
try modelContext.save()
self.logger.info("Cleanup complete. Deleted \(deletedCount) files. Failed: \(errorCount)")
} catch {
self.logger.error("Error saving model context after cleanup: \(error.localizedDescription)")
}
}
return (deletedCount, errorCount)
}
} catch {
logger.error("Error during targeted cleanup: \(error.localizedDescription)")
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)
}
}

View File

@ -0,0 +1,132 @@
import SwiftUI
import SwiftData
/// A view component for configuring audio cleanup settings
struct AudioCleanupSettingsView: View {
@EnvironmentObject private var whisperState: WhisperState
// Audio cleanup settings
@AppStorage("IsAudioCleanupEnabled") private var isAudioCleanupEnabled = true
@AppStorage("AudioRetentionPeriod") private var audioRetentionPeriod = 7
@State private var isPerformingCleanup = false
@State private var isShowingConfirmation = false
@State private var cleanupInfo: (fileCount: Int, totalSize: Int64, transcriptions: [Transcription]) = (0, 0, [])
@State private var showResultAlert = false
@State private var cleanupResult: (deletedCount: Int, errorCount: Int) = (0, 0)
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("VoiceInk can automatically delete audio files from transcription history while preserving the text transcripts.")
.font(.system(size: 13))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Toggle("Enable automatic audio cleanup", isOn: $isAudioCleanupEnabled)
.toggleStyle(.switch)
.padding(.vertical, 4)
if isAudioCleanupEnabled {
VStack(alignment: .leading, spacing: 8) {
Text("Retention Period")
.font(.system(size: 14, weight: .medium))
Picker("Keep audio files for", selection: $audioRetentionPeriod) {
Text("1 day").tag(1)
Text("3 days").tag(3)
Text("7 days").tag(7)
Text("14 days").tag(14)
Text("30 days").tag(30)
}
.pickerStyle(.menu)
Text("Audio files older than the selected period will be automatically deleted, while keeping the text transcripts intact.")
.font(.system(size: 13))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
}
.padding(.vertical, 4)
Button(action: {
// Start by analyzing what would be cleaned up
Task {
// Update UI state
await MainActor.run {
isPerformingCleanup = true
}
// Get cleanup info
let info = await AudioCleanupManager.shared.getCleanupInfo(modelContext: whisperState.modelContext)
// Update UI with results
await MainActor.run {
cleanupInfo = info
isPerformingCleanup = false
isShowingConfirmation = true
}
}
}) {
HStack {
if isPerformingCleanup {
ProgressView()
.controlSize(.small)
.padding(.trailing, 4)
} else {
Image(systemName: "arrow.clockwise")
}
Text(isPerformingCleanup ? "Analyzing..." : "Run Cleanup Now")
}
}
.buttonStyle(.bordered)
.controlSize(.large)
.disabled(isPerformingCleanup)
.alert("Audio Cleanup", isPresented: $isShowingConfirmation) {
Button("Cancel", role: .cancel) { }
if cleanupInfo.fileCount > 0 {
Button("Delete \(cleanupInfo.fileCount) Files", role: .destructive) {
Task {
// Update UI state
await MainActor.run {
isPerformingCleanup = true
}
// Perform cleanup
let result = await AudioCleanupManager.shared.runCleanupForTranscriptions(
modelContext: whisperState.modelContext,
transcriptions: cleanupInfo.transcriptions
)
// Update UI with results
await MainActor.run {
cleanupResult = result
isPerformingCleanup = false
showResultAlert = true
}
}
}
}
} message: {
VStack(alignment: .leading, spacing: 8) {
if cleanupInfo.fileCount > 0 {
Text("This will delete \(cleanupInfo.fileCount) audio files older than \(audioRetentionPeriod) day\(audioRetentionPeriod > 1 ? "s" : "").")
Text("Total size to be freed: \(AudioCleanupManager.shared.formatFileSize(cleanupInfo.totalSize))")
Text("The text transcripts will be preserved.")
} else {
Text("No audio files found that are older than \(audioRetentionPeriod) day\(audioRetentionPeriod > 1 ? "s" : "").")
}
}
}
.alert("Cleanup Complete", isPresented: $showResultAlert) {
Button("OK", role: .cancel) { }
} message: {
if cleanupResult.errorCount > 0 {
Text("Successfully deleted \(cleanupResult.deletedCount) audio files. Failed to delete \(cleanupResult.errorCount) files.")
} else {
Text("Successfully deleted \(cleanupResult.deletedCount) audio files.")
}
}
}
}
}
}

View File

@ -3,6 +3,7 @@ import Cocoa
import KeyboardShortcuts
import LaunchAtLogin
import AVFoundation
// Additional imports for Settings components
struct SettingsView: View {
@EnvironmentObject private var updaterViewModel: UpdaterViewModel
@ -165,6 +166,15 @@ struct SettingsView: View {
}
}
// Audio Cleanup Section
SettingsSection(
icon: "trash.circle",
title: "Audio Cleanup",
subtitle: "Manage recording storage"
) {
AudioCleanupSettingsView()
}
// Reset Onboarding Section
SettingsSection(
icon: "arrow.counterclockwise",

View File

@ -2,6 +2,7 @@ import SwiftUI
import SwiftData
import Sparkle
import AppKit
import OSLog
@main
struct VoiceInkApp: App {
@ -17,6 +18,9 @@ struct VoiceInkApp: App {
@StateObject private var activeWindowService = ActiveWindowService.shared
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
// Audio cleanup manager for automatic deletion of old audio files
private let audioCleanupManager = AudioCleanupManager.shared
init() {
do {
let schema = Schema([
@ -90,12 +94,18 @@ struct VoiceInkApp: App {
.modelContainer(container)
.onAppear {
updaterViewModel.silentlyCheckForUpdates()
// Start the automatic audio cleanup process
audioCleanupManager.startAutomaticCleanup(modelContext: container.mainContext)
}
.background(WindowAccessor { window in
WindowManager.shared.configureWindow(window)
})
.onDisappear {
whisperState.unloadModel()
// Stop the automatic audio cleanup process
audioCleanupManager.stopAutomaticCleanup()
}
} else {
OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)