mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
Yattee v2 rewrite
This commit is contained in:
279
Yattee/Services/Downloads/DownloadManager+Storage.swift
Normal file
279
Yattee/Services/Downloads/DownloadManager+Storage.swift
Normal file
@@ -0,0 +1,279 @@
|
||||
//
|
||||
// DownloadManager+Storage.swift
|
||||
// Yattee
|
||||
//
|
||||
// Storage management and orphan detection for DownloadManager.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if !os(tvOS)
|
||||
|
||||
extension DownloadManager {
|
||||
// MARK: - Storage Management
|
||||
|
||||
/// Calculate total storage used by downloads (file operations run on background thread).
|
||||
@discardableResult
|
||||
func calculateStorageUsed() async -> Int64 {
|
||||
// Capture download paths on main thread
|
||||
let downloads = completedDownloads
|
||||
let baseDir = downloadsDirectory()
|
||||
|
||||
// Calculate on background thread to avoid blocking UI
|
||||
let total = await Task.detached {
|
||||
let fm = FileManager.default
|
||||
var total: Int64 = 0
|
||||
|
||||
for download in downloads {
|
||||
// Count video file
|
||||
if let videoPath = download.localVideoPath {
|
||||
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(videoPath).path, fileManager: fm)
|
||||
}
|
||||
// Count audio file
|
||||
if let audioPath = download.localAudioPath {
|
||||
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(audioPath).path, fileManager: fm)
|
||||
}
|
||||
// Count caption file
|
||||
if let captionPath = download.localCaptionPath {
|
||||
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(captionPath).path, fileManager: fm)
|
||||
}
|
||||
// Count storyboard directory
|
||||
if let storyboardPath = download.localStoryboardPath {
|
||||
total += Self.directorySizeBackground(at: baseDir.appendingPathComponent(storyboardPath), fileManager: fm)
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}.value
|
||||
|
||||
// Update published property on main thread
|
||||
storageUsed = total
|
||||
return total
|
||||
}
|
||||
|
||||
/// Background-safe file size calculation (nonisolated static method)
|
||||
nonisolated static func fileSizeBackground(at path: String, fileManager: FileManager) -> Int64 {
|
||||
do {
|
||||
let attrs = try fileManager.attributesOfItem(atPath: path)
|
||||
return attrs[.size] as? Int64 ?? 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Background-safe directory size calculation (nonisolated static method)
|
||||
nonisolated static func directorySizeBackground(at url: URL, fileManager: FileManager) -> Int64 {
|
||||
guard fileManager.fileExists(atPath: url.path) else { return 0 }
|
||||
|
||||
var size: Int64 = 0
|
||||
if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) {
|
||||
for case let fileURL as URL in enumerator {
|
||||
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
size += Int64(fileSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
/// Get available storage on device.
|
||||
func getAvailableStorage() -> Int64 {
|
||||
do {
|
||||
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let values = try documentsURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||
return values.volumeAvailableCapacityForImportantUsage ?? 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate total size of a directory
|
||||
func directorySize(at url: URL) -> Int64 {
|
||||
guard fileManager.fileExists(atPath: url.path) else { return 0 }
|
||||
|
||||
var size: Int64 = 0
|
||||
if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) {
|
||||
for case let fileURL as URL in enumerator {
|
||||
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
size += Int64(fileSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// MARK: - Orphan Detection & Cleanup
|
||||
|
||||
/// Represents an orphaned file not tracked by any download record.
|
||||
struct OrphanedFile: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
let fileName: String
|
||||
let size: Int64
|
||||
}
|
||||
|
||||
/// Scan the downloads directory for orphaned files not tracked by any download record.
|
||||
/// Returns a list of orphaned files with their sizes.
|
||||
func findOrphanedFiles() -> (orphanedFiles: [OrphanedFile], totalOrphanedSize: Int64, trackedSize: Int64, actualDiskSize: Int64) {
|
||||
let downloadsDir = downloadsDirectory()
|
||||
|
||||
// Build set of all tracked file paths
|
||||
var trackedPaths = Set<String>()
|
||||
var trackedSize: Int64 = 0
|
||||
|
||||
// From completed downloads
|
||||
for download in completedDownloads {
|
||||
if let videoPath = download.localVideoPath {
|
||||
trackedPaths.insert(videoPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(videoPath).path)
|
||||
}
|
||||
if let audioPath = download.localAudioPath {
|
||||
trackedPaths.insert(audioPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(audioPath).path)
|
||||
}
|
||||
if let captionPath = download.localCaptionPath {
|
||||
trackedPaths.insert(captionPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(captionPath).path)
|
||||
}
|
||||
if let storyboardPath = download.localStoryboardPath {
|
||||
trackedPaths.insert(storyboardPath)
|
||||
trackedSize += directorySize(at: downloadsDir.appendingPathComponent(storyboardPath))
|
||||
}
|
||||
if let thumbnailPath = download.localThumbnailPath {
|
||||
trackedPaths.insert(thumbnailPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(thumbnailPath).path)
|
||||
}
|
||||
if let channelThumbnailPath = download.localChannelThumbnailPath {
|
||||
trackedPaths.insert(channelThumbnailPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(channelThumbnailPath).path)
|
||||
}
|
||||
}
|
||||
|
||||
// From active downloads (in progress)
|
||||
for download in activeDownloads {
|
||||
if let videoPath = download.localVideoPath {
|
||||
trackedPaths.insert(videoPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(videoPath).path)
|
||||
}
|
||||
if let audioPath = download.localAudioPath {
|
||||
trackedPaths.insert(audioPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(audioPath).path)
|
||||
}
|
||||
if let captionPath = download.localCaptionPath {
|
||||
trackedPaths.insert(captionPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(captionPath).path)
|
||||
}
|
||||
if let storyboardPath = download.localStoryboardPath {
|
||||
trackedPaths.insert(storyboardPath)
|
||||
trackedSize += directorySize(at: downloadsDir.appendingPathComponent(storyboardPath))
|
||||
}
|
||||
if let thumbnailPath = download.localThumbnailPath {
|
||||
trackedPaths.insert(thumbnailPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(thumbnailPath).path)
|
||||
}
|
||||
if let channelThumbnailPath = download.localChannelThumbnailPath {
|
||||
trackedPaths.insert(channelThumbnailPath)
|
||||
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(channelThumbnailPath).path)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan directory for all files and directories
|
||||
var orphanedFiles: [OrphanedFile] = []
|
||||
var totalOrphanedSize: Int64 = 0
|
||||
var actualDiskSize: Int64 = 0
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: downloadsDir, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
|
||||
for fileURL in contents {
|
||||
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
let fileName = fileURL.lastPathComponent
|
||||
let isDirectory = resourceValues.isDirectory == true
|
||||
|
||||
let size: Int64
|
||||
if isDirectory {
|
||||
size = directorySize(at: fileURL)
|
||||
} else {
|
||||
size = Int64(resourceValues.fileSize ?? 0)
|
||||
}
|
||||
|
||||
actualDiskSize += size
|
||||
|
||||
if !trackedPaths.contains(fileName) {
|
||||
orphanedFiles.append(OrphanedFile(url: fileURL, fileName: fileName, size: size))
|
||||
totalOrphanedSize += size
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
LoggingService.shared.logDownloadError("Failed to scan downloads directory for orphans", error: error)
|
||||
}
|
||||
|
||||
return (orphanedFiles, totalOrphanedSize, trackedSize, actualDiskSize)
|
||||
}
|
||||
|
||||
/// Log detailed diagnostic information about orphaned files.
|
||||
/// Call this to debug storage discrepancy issues.
|
||||
func logOrphanDiagnostics() {
|
||||
let (orphanedFiles, totalOrphanedSize, trackedSize, actualDiskSize) = findOrphanedFiles()
|
||||
|
||||
LoggingService.shared.logDownload(
|
||||
"=== DOWNLOAD STORAGE DIAGNOSTICS ===",
|
||||
details: """
|
||||
Tracked downloads: \(completedDownloads.count) completed, \(activeDownloads.count) active
|
||||
Tracked file size: \(formatBytes(trackedSize))
|
||||
Actual disk usage: \(formatBytes(actualDiskSize))
|
||||
Orphaned files: \(orphanedFiles.count) (\(formatBytes(totalOrphanedSize)))
|
||||
Discrepancy: \(formatBytes(actualDiskSize - trackedSize))
|
||||
"""
|
||||
)
|
||||
|
||||
if !orphanedFiles.isEmpty {
|
||||
LoggingService.shared.logDownload("=== ORPHANED FILES ===")
|
||||
for file in orphanedFiles.sorted(by: { $0.size > $1.size }) {
|
||||
LoggingService.shared.logDownload(
|
||||
"Orphan: \(file.fileName)",
|
||||
details: "Size: \(formatBytes(file.size))"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all orphaned files not tracked by any download record.
|
||||
/// Returns the number of files deleted and total bytes freed.
|
||||
@discardableResult
|
||||
func deleteOrphanedFiles() async -> (deletedCount: Int, bytesFreed: Int64) {
|
||||
let (orphanedFiles, _, _, _) = findOrphanedFiles()
|
||||
|
||||
var deletedCount = 0
|
||||
var bytesFreed: Int64 = 0
|
||||
|
||||
for file in orphanedFiles {
|
||||
do {
|
||||
try fileManager.removeItem(at: file.url)
|
||||
deletedCount += 1
|
||||
bytesFreed += file.size
|
||||
LoggingService.shared.logDownload("Deleted orphan: \(file.fileName)", details: "Freed: \(formatBytes(file.size))")
|
||||
} catch {
|
||||
LoggingService.shared.logDownloadError("Failed to delete orphan: \(file.fileName)", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
LoggingService.shared.logDownload(
|
||||
"Orphan cleanup complete",
|
||||
details: "Deleted \(deletedCount) files, freed \(formatBytes(bytesFreed))"
|
||||
)
|
||||
await calculateStorageUsed()
|
||||
}
|
||||
|
||||
return (deletedCount, bytesFreed)
|
||||
}
|
||||
|
||||
func formatBytes(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user