Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,18 +363,19 @@ class EnhancedFileManager: ObservableObject {
}
}

// Delete associated location file if it exists
let locationURL = normalizedURL.deletingPathExtension().appendingPathExtension("location")
if FileManager.default.fileExists(atPath: locationURL.path) {
do {
try FileManager.default.removeItem(at: locationURL)
AppLog.shared.fileManagement("Deleted location file: \(locationURL.lastPathComponent)")
} catch {
if error.isThumbnailGenerationError {
AppLog.shared.fileManagement("Thumbnail generation warning during location file deletion: \(error.localizedDescription)", level: .debug)
// Continue with deletion even if thumbnail generation fails
} else {
throw error
// Delete associated sidecar files if they exist
for ext in ["location", "recordingmeta"] {
let sidecarURL = normalizedURL.deletingPathExtension().appendingPathExtension(ext)
if FileManager.default.fileExists(atPath: sidecarURL.path) {
do {
try FileManager.default.removeItem(at: sidecarURL)
AppLog.shared.fileManagement("Deleted \(ext) file: \(sidecarURL.lastPathComponent)")
} catch {
if error.isThumbnailGenerationError {
AppLog.shared.fileManagement("Thumbnail generation warning during \(ext) file deletion: \(error.localizedDescription)", level: .debug)
} else {
throw error
}
}
}
}
Expand Down Expand Up @@ -669,6 +670,11 @@ class EnhancedFileManager: ObservableObject {

if !dryRun {
try FileManager.default.removeItem(at: file)
// Also remove sidecar files for the orphaned audio
for ext in ["location", "recordingmeta"] {
let sidecarURL = file.deletingPathExtension().appendingPathExtension(ext)
try? FileManager.default.removeItem(at: sidecarURL)
}
AppLog.shared.fileManagement("Deleted orphaned file: \(file.lastPathComponent) (\(fileSize) bytes)")
deletedCount += 1
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ class RecordingArchiveService: ObservableObject {
} catch {
AppLog.shared.recording("Archived: failed to remove local audio: \(error.localizedDescription)", level: .error)
}
// Clean up sidecar files alongside the audio
for ext in ["location", "recordingmeta"] {
let sidecarURL = url.deletingPathExtension().appendingPathExtension(ext)
try? FileManager.default.removeItem(at: sidecarURL)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -424,18 +424,7 @@ extension AudioRecorderViewModel {
let originalFilename = currentURL.deletingPathExtension().lastPathComponent
let duration = getRecordingDuration(url: currentURL)

// Get file creation date, or use current date as fallback
let recordingDate: Date
do {
let attributes = try FileManager.default.attributesOfItem(atPath: currentURL.path)
if let creationDate = attributes[.creationDate] as? Date {
recordingDate = creationDate
} else {
recordingDate = Date()
}
} catch {
recordingDate = Date()
}
let recordingDate = currentRecordingDate(for: currentURL)

_ = workflowManager.createRecording(
url: currentURL,
Expand All @@ -446,6 +435,7 @@ extension AudioRecorderViewModel {
quality: quality,
locationData: recordingStartLocationData
)
recordingStartedAt = nil
}
}
}
Expand Down Expand Up @@ -583,7 +573,7 @@ extension AudioRecorderViewModel {
let recordingId = workflowManager.createRecording(
url: url,
name: displayName,
date: Date(),
date: currentRecordingDate(for: url),
fileSize: fileSize,
duration: duration,
quality: quality,
Expand All @@ -598,6 +588,7 @@ extension AudioRecorderViewModel {
// Reset processing flag
recordingBeingProcessed = false
resetRecordingLocation()
recordingStartedAt = nil

// End background task after successful recovery and save
endBackgroundTask()
Expand Down Expand Up @@ -708,7 +699,7 @@ extension AudioRecorderViewModel {
let recordingId = workflowManager.createRecording(
url: url,
name: displayName,
date: Date(),
date: currentRecordingDate(for: url),
fileSize: fileSize,
duration: duration,
quality: quality,
Expand All @@ -724,6 +715,7 @@ extension AudioRecorderViewModel {
self.recordingURL = nil
self.recordingBeingProcessed = false
self.resetRecordingLocation()
self.recordingStartedAt = nil
}

// Send notification to user about recovery (with slight delay to improve visibility)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ extension AudioRecorderViewModel {
let recordingId = workflowManager.createRecording(
url: mainURL,
name: displayName,
date: Date(),
date: currentRecordingDate(for: mainURL),
fileSize: fileSize,
duration: duration,
quality: quality,
Expand All @@ -137,6 +137,7 @@ extension AudioRecorderViewModel {
AppLog.shared.recording("Merged recording created with workflow manager, ID: \(recordingId)")

self.resetRecordingLocation()
self.recordingStartedAt = nil
} else {
AppLog.shared.recording("WorkflowManager not set - merged recording not saved to database", level: .error)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import Foundation
@preconcurrency import AVFoundation
import UserNotifications

private struct RecordingTimestampMetadata: Codable {
let recordedAt: Date
}

// MARK: - AVAudioRecorderDelegate

extension AudioRecorderViewModel: AVAudioRecorderDelegate {
Expand Down Expand Up @@ -77,7 +81,7 @@ extension AudioRecorderViewModel: AVAudioRecorderDelegate {
let recordingId = workflowManager.createRecording(
url: recordingURL,
name: displayName,
date: Date(),
date: currentRecordingDate(for: recordingURL),
fileSize: fileSize,
duration: duration,
quality: quality,
Expand All @@ -88,6 +92,7 @@ extension AudioRecorderViewModel: AVAudioRecorderDelegate {

// Watch audio integration removed
self.resetRecordingLocation()
self.recordingStartedAt = nil
} else {
AppLog.shared.recording("WorkflowManager not set - recording not saved to database", level: .error)
}
Expand Down Expand Up @@ -276,6 +281,37 @@ extension AudioRecorderViewModel {
// Final fallback to the timer value we tracked during recording
return recordingTime
}

func currentRecordingDate(for url: URL?) -> Date {
if let entry = recordingStartedAt, let url, entry.url == url {
return entry.date
}

if let url,
let data = try? Data(contentsOf: recordingTimestampMetadataURL(for: url)),
let metadata = try? JSONDecoder().decode(RecordingTimestampMetadata.self, from: data) {
return metadata.recordedAt
}

return Date()
}

func persistRecordingCapturedAt(_ date: Date, for url: URL) {
let metadata = RecordingTimestampMetadata(recordedAt: date)
guard let data = try? JSONEncoder().encode(metadata) else {
return
}

do {
try data.write(to: recordingTimestampMetadataURL(for: url), options: .atomic)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clean up .recordingmeta sidecars on recording deletion

persistRecordingCapturedAt now creates a .recordingmeta sidecar for every recording, but there is no corresponding cleanup path for that file type (for example, EnhancedFileManager.deleteRecording removes only the audio file and .location, and the fallback deletes in RecordingsListView/TranscriptViews also remove only known file types). This leaves orphaned metadata files behind whenever a recording is deleted, which introduces silent storage growth and stale on-disk artifacts over time.

Useful? React with 👍 / 👎.

} catch {
AppLog.shared.recording("Failed to write recording timestamp metadata: \(error.localizedDescription)", level: .error)
}
}

private func recordingTimestampMetadataURL(for recordingURL: URL) -> URL {
recordingURL.deletingPathExtension().appendingPathExtension("recordingmeta")
}
}

// MARK: - Naming Convention
Expand All @@ -292,7 +328,7 @@ extension AudioRecorderViewModel {
func generateAppRecordingDisplayName() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
let timestamp = formatter.string(from: Date())
let timestamp = formatter.string(from: recordingStartedAt?.date ?? Date())
return "apprecording-\(timestamp)"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject {

// Track last checkpoint time for periodic data flushing
var lastCheckpointTime: Date = Date.distantPast
var recordingStartedAt: (url: URL, date: Date)?
let checkpointInterval: TimeInterval = 30.0 // Try to checkpoint every 30 seconds
let forceCheckpointInterval: TimeInterval = 90.0 // Force checkpoint after 90 seconds even without silence

Expand Down Expand Up @@ -454,7 +455,10 @@ class AudioRecorderViewModel: NSObject, ObservableObject {
func setupRecording() {
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let audioFilename = documentsPath.appendingPathComponent(generateAppRecordingFilename())
let recordingStartDate = Date()
recordingURL = audioFilename
recordingStartedAt = (url: audioFilename, date: recordingStartDate)
persistRecordingCapturedAt(recordingStartDate, for: audioFilename)

// Initialize segment tracking for this new recording
mainRecordingURL = audioFilename
Expand Down Expand Up @@ -600,7 +604,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject {
let recordingId = workflowManager.createRecording(
url: url,
name: displayName,
date: Date(),
date: currentRecordingDate(for: url),
fileSize: fileSize,
duration: duration,
quality: quality,
Expand Down Expand Up @@ -630,6 +634,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject {
}

resetRecordingLocation()
recordingStartedAt = nil
endBackgroundTask()
}

Expand Down
8 changes: 5 additions & 3 deletions BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -713,9 +713,11 @@ struct TranscriptsView: View {
// Delete the associated dummy audio file if it exists
if let recordingURL = appCoordinator.getAbsoluteURL(for: importedTranscript.recording) {
try? FileManager.default.removeItem(at: recordingURL)
// Delete associated location file if present
let locationURL = recordingURL.deletingPathExtension().appendingPathExtension("location")
try? FileManager.default.removeItem(at: locationURL)
// Delete associated sidecar files if present
for ext in ["location", "recordingmeta"] {
let sidecarURL = recordingURL.deletingPathExtension().appendingPathExtension(ext)
try? FileManager.default.removeItem(at: sidecarURL)
}
AppLog.shared.transcription("Deleted dummy audio file: \(recordingURL.lastPathComponent)", level: .debug)
}

Expand Down
Loading