From b3933f0da48101b31774b67a4580debd378ff127 Mon Sep 17 00:00:00 2001 From: realdoomsboygaming <105606471+realdoomsboygaming@users.noreply.github.com> Date: Sat, 26 Jul 2025 06:55:25 -0500 Subject: [PATCH] Add orphaned file management features (#231) --- .../Localization/ar.lproj/Localizable.strings | 9 + .../bos.lproj/Localizable.strings | 9 + .../Localization/cs.lproj/Localizable.strings | 11 +- .../Localization/de.lproj/Localizable.strings | 9 + .../Localization/en.lproj/Localizable.strings | 9 + .../Localization/es.lproj/Localizable.strings | 9 + .../Localization/fr.lproj/Localizable.strings | 9 + .../Localization/it.lproj/Localizable.strings | 9 + .../Localization/kk.lproj/Localizable.strings | 11 +- .../Localization/mn.lproj/Localizable.strings | 11 +- .../Localization/nl.lproj/Localizable.strings | 11 +- .../Localization/nn.lproj/Localizable.strings | 11 +- .../Localization/ru.lproj/Localizable.strings | 11 +- .../Localization/sk.lproj/Localizable.strings | 9 + .../Localization/sv.lproj/Localizable.strings | 11 +- .../DownloadUtils/DownloadPersistence.swift | 21 ++ .../OrphanedDownloadsView.swift | 248 ++++++++++++++++++ .../SettingsViewDownloads.swift | 115 ++++++-- Sulfur.xcodeproj/project.pbxproj | 4 + 19 files changed, 504 insertions(+), 33 deletions(-) create mode 100644 Sora/Views/SettingsView/SettingsSubViews/OrphanedDownloadsView.swift diff --git a/Sora/Localization/ar.lproj/Localizable.strings b/Sora/Localization/ar.lproj/Localizable.strings index d1fbb6e..0b6c60e 100644 --- a/Sora/Localization/ar.lproj/Localizable.strings +++ b/Sora/Localization/ar.lproj/Localizable.strings @@ -490,3 +490,12 @@ "Import Backup" = "استيراد النسخة الاحتياطية"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "تنبيه: هذه الميزة لا تزال تجريبية. يرجى التحقق من بياناتك بعد التصدير/الاستيراد."; "Backup" = "نسخة احتياطية"; + +"Total Orphaned File Size" = "إجمالي حجم الملفات اليتيمة"; +"Total Orphaned Files" = "إجمالي عدد الملفات اليتيمة"; + +"Orphaned Downloads" = "التنزيلات اليتيمة"; +"No orphaned files found." = "لم يتم العثور على ملفات يتيمة."; +"Delete All Orphaned Files" = "حذف جميع الملفات اليتيمة"; +"Delete Selected Files?" = "حذف الملفات المحددة؟"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "هل أنت متأكد أنك تريد حذف الملفات اليتيمة المحددة؟ لا يمكن التراجع عن هذا الإجراء."; diff --git a/Sora/Localization/bos.lproj/Localizable.strings b/Sora/Localization/bos.lproj/Localizable.strings index f2ec2ea..0191ba2 100644 --- a/Sora/Localization/bos.lproj/Localizable.strings +++ b/Sora/Localization/bos.lproj/Localizable.strings @@ -508,3 +508,12 @@ Za metapodatke epizode, odnosi se na sličicu i naslov epizode, jer ponekad mogu "Import Backup" = "Uvezi sigurnosnu kopiju"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Napomena: Ova funkcija je još uvijek eksperimentalna. Molimo provjerite svoje podatke nakon izvoza/uvoza."; "Backup" = "Sigurnosna kopija"; + +"Total Orphaned File Size" = "Ukupna veličina siročadnih datoteka"; +"Total Orphaned Files" = "Ukupan broj siročadnih datoteka"; + +"Orphaned Downloads" = "Siročadne preuzimanja"; +"No orphaned files found." = "Nema pronađenih siročadnih datoteka."; +"Delete All Orphaned Files" = "Obriši sve siročadne datoteke"; +"Delete Selected Files?" = "Obrisati odabrane datoteke?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Jeste li sigurni da želite obrisati odabrane siročadne datoteke? Ova radnja se ne može poništiti."; diff --git a/Sora/Localization/cs.lproj/Localizable.strings b/Sora/Localization/cs.lproj/Localizable.strings index 8f7b19e..2940aa7 100644 --- a/Sora/Localization/cs.lproj/Localizable.strings +++ b/Sora/Localization/cs.lproj/Localizable.strings @@ -508,4 +508,13 @@ Metadata epizody se týkají náhledu a názvu epizody, které mohou někdy obsa "Export Backup" = "Exportovat zálohu"; "Import Backup" = "Importovat zálohu"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Upozornění: Tato funkce je stále experimentální. Po exportu/importu si prosím zkontrolujte svá data."; -"Backup" = "Záloha"; \ No newline at end of file +"Backup" = "Záloha"; + +"Total Orphaned File Size" = "Celková velikost osiřelých souborů"; +"Total Orphaned Files" = "Celkový počet osiřelých souborů"; + +"Orphaned Downloads" = "Osiřelé stahování"; +"No orphaned files found." = "Nebyly nalezeny žádné osiřelé soubory."; +"Delete All Orphaned Files" = "Smazat všechny osiřelé soubory"; +"Delete Selected Files?" = "Smazat vybrané soubory?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Opravdu chcete smazat vybrané osiřelé soubory? Tato akce je nevratná."; \ No newline at end of file diff --git a/Sora/Localization/de.lproj/Localizable.strings b/Sora/Localization/de.lproj/Localizable.strings index 0103581..de7f4c8 100644 --- a/Sora/Localization/de.lproj/Localizable.strings +++ b/Sora/Localization/de.lproj/Localizable.strings @@ -497,3 +497,12 @@ "Import Backup" = "Backup importieren"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Hinweis: Diese Funktion ist noch experimentell. Bitte überprüfe deine Daten nach dem Export/Import."; "Backup" = "Backup"; + +"Total Orphaned File Size" = "Gesamte Größe verwaister Dateien"; +"Total Orphaned Files" = "Anzahl verwaister Dateien"; + +"Orphaned Downloads" = "Verwaiste Downloads"; +"No orphaned files found." = "Keine verwaisten Dateien gefunden."; +"Delete All Orphaned Files" = "Alle verwaisten Dateien löschen"; +"Delete Selected Files?" = "Ausgewählte Dateien löschen?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Möchten Sie die ausgewählten verwaisten Dateien wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."; diff --git a/Sora/Localization/en.lproj/Localizable.strings b/Sora/Localization/en.lproj/Localizable.strings index 06d2550..16b0f4f 100644 --- a/Sora/Localization/en.lproj/Localizable.strings +++ b/Sora/Localization/en.lproj/Localizable.strings @@ -459,3 +459,12 @@ "Translators" = "Translators"; "Paste URL" = "Paste URL"; +"Total Orphaned File Size" = "Total Orphaned File Size"; +"Total Orphaned Files" = "Total Orphaned Files"; + +"Orphaned Downloads" = "Orphaned Downloads"; +"No orphaned files found." = "No orphaned files found."; +"Delete All Orphaned Files" = "Delete All Orphaned Files"; +"Delete Selected Files?" = "Delete Selected Files?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Are you sure you want to delete the selected orphaned files? This action cannot be undone."; + diff --git a/Sora/Localization/es.lproj/Localizable.strings b/Sora/Localization/es.lproj/Localizable.strings index ac10a76..ca1975d 100644 --- a/Sora/Localization/es.lproj/Localizable.strings +++ b/Sora/Localization/es.lproj/Localizable.strings @@ -504,3 +504,12 @@ Para los metadatos del episodio, se refiere a la miniatura y el título del epis "Notice: This feature is still experimental. Please double-check your data after import/export." = "Aviso: Esta función aún es experimental. Por favor, verifica tus datos después de exportar/importar."; "Backup" = "Copia de seguridad"; +"Total Orphaned File Size" = "Tamaño total de archivos huérfanos"; +"Total Orphaned Files" = "Total de archivos huérfanos"; + +"Orphaned Downloads" = "Descargas huérfanas"; +"No orphaned files found." = "No se encontraron archivos huérfanos."; +"Delete All Orphaned Files" = "Eliminar todos los archivos huérfanos"; +"Delete Selected Files?" = "¿Eliminar archivos seleccionados?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "¿Está seguro de que desea eliminar los archivos huérfanos seleccionados? Esta acción no se puede deshacer."; + diff --git a/Sora/Localization/fr.lproj/Localizable.strings b/Sora/Localization/fr.lproj/Localizable.strings index 27a6959..2f0c938 100644 --- a/Sora/Localization/fr.lproj/Localizable.strings +++ b/Sora/Localization/fr.lproj/Localizable.strings @@ -492,3 +492,12 @@ "Import Backup" = "Importer la sauvegarde"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Remarque : Cette fonctionnalité est encore expérimentale. Veuillez vérifier vos données après l'export/import."; "Backup" = "Sauvegarde"; + +"Total Orphaned File Size" = "Taille totale des fichiers orphelins"; +"Total Orphaned Files" = "Nombre total de fichiers orphelins"; + +"Orphaned Downloads" = "Téléchargements orphelins"; +"No orphaned files found." = "Aucun fichier orphelin trouvé."; +"Delete All Orphaned Files" = "Supprimer tous les fichiers orphelins"; +"Delete Selected Files?" = "Supprimer les fichiers sélectionnés ?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Êtes-vous sûr de vouloir supprimer les fichiers orphelins sélectionnés ? Cette action est irréversible."; diff --git a/Sora/Localization/it.lproj/Localizable.strings b/Sora/Localization/it.lproj/Localizable.strings index 4a90535..b69e522 100644 --- a/Sora/Localization/it.lproj/Localizable.strings +++ b/Sora/Localization/it.lproj/Localizable.strings @@ -423,3 +423,12 @@ "Import Backup" = "Importa backup"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Nota: Questa funzione è ancora sperimentale. Si prega di ricontrollare i dati dopo esportazione/importazione."; "Backup" = "Backup"; + +"Total Orphaned File Size" = "Dimensione totale dei file orfani"; +"Total Orphaned Files" = "Numero totale di file orfani"; + +"Orphaned Downloads" = "Download orfani"; +"No orphaned files found." = "Nessun file orfano trovato."; +"Delete All Orphaned Files" = "Elimina tutti i file orfani"; +"Delete Selected Files?" = "Eliminare i file selezionati?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Sei sicuro di voler eliminare i file orfani selezionati? Questa azione non può essere annullata."; diff --git a/Sora/Localization/kk.lproj/Localizable.strings b/Sora/Localization/kk.lproj/Localizable.strings index ba6afce..be50e52 100644 --- a/Sora/Localization/kk.lproj/Localizable.strings +++ b/Sora/Localization/kk.lproj/Localizable.strings @@ -503,4 +503,13 @@ For episode metadata, it refers to the episode thumbnail and title, since someti "Export Backup" = "Сақтық көшірмені экспорттау"; "Import Backup" = "Сақтық көшірмені импорттау"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Ескерту: Бұл мүмкіндік әлі де тәжірибелік. Экспорт/импорттан кейін деректеріңізді тексеріңіз."; -"Backup" = "Сақтық көшірме"; \ No newline at end of file +"Backup" = "Сақтық көшірме"; + +"Total Orphaned File Size" = "Жалпы жетім файлдардың өлшемі"; +"Total Orphaned Files" = "Жетім файлдардың жалпы саны"; + +"Orphaned Downloads" = "Жетім жүктеулер"; +"No orphaned files found." = "Жетім файлдар табылмады."; +"Delete All Orphaned Files" = "Барлық жетім файлдарды жою"; +"Delete Selected Files?" = "Таңдалған файлдарды жою керек пе?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Таңдалған жетім файлдарды жойғыңыз келетініне сенімдісіз бе? Бұл әрекет қайтарылмайды."; diff --git a/Sora/Localization/mn.lproj/Localizable.strings b/Sora/Localization/mn.lproj/Localizable.strings index c5e820a..463348c 100644 --- a/Sora/Localization/mn.lproj/Localizable.strings +++ b/Sora/Localization/mn.lproj/Localizable.strings @@ -464,4 +464,13 @@ Sora ба cranci1 нь AniList эсвэл Trakt-тэй ямар ч хамаар "Watch Progress" = "Үзсэн явц"; "Recent searches" = "Саяхны хайлт"; "Collections" = "Цуглуулгууд"; -"Continue Reading" = "Унших үргэлжлүүлэх"; \ No newline at end of file +"Continue Reading" = "Унших үргэлжлүүлэх"; + +"Total Orphaned File Size" = "Орхигдсон файлуудын нийт хэмжээ"; +"Total Orphaned Files" = "Орхигдсон файлуудын нийт тоо"; + +"Orphaned Downloads" = "Орхигдсон таталтууд"; +"No orphaned files found." = "Орхигдсон файл олдсонгүй."; +"Delete All Orphaned Files" = "Бүх орхигдсон файлуудыг устгах"; +"Delete Selected Files?" = "Сонгосон файлуудыг устгах уу?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Сонгосон орхигдсон файлуудыг устгахдаа итгэлтэй байна уу? Энэ үйлдлийг буцаах боломжгүй."; \ No newline at end of file diff --git a/Sora/Localization/nl.lproj/Localizable.strings b/Sora/Localization/nl.lproj/Localizable.strings index 3cc1545..4737d55 100644 --- a/Sora/Localization/nl.lproj/Localizable.strings +++ b/Sora/Localization/nl.lproj/Localizable.strings @@ -485,4 +485,13 @@ "Export Backup" = "Back-up exporteren"; "Import Backup" = "Back-up importeren"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Let op: Deze functie is nog experimenteel. Controleer je gegevens na export/import."; -"Backup" = "Back-up"; +"Backup" = "Back-up"; + +"Total Orphaned File Size" = "Totale grootte van verweesde bestanden"; +"Total Orphaned Files" = "Totaal aantal verweesde bestanden"; + +"Orphaned Downloads" = "Verweesde downloads"; +"No orphaned files found." = "Geen verweesde bestanden gevonden."; +"Delete All Orphaned Files" = "Alle verweesde bestanden verwijderen"; +"Delete Selected Files?" = "Geselecteerde bestanden verwijderen?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Weet u zeker dat u de geselecteerde verweesde bestanden wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt."; diff --git a/Sora/Localization/nn.lproj/Localizable.strings b/Sora/Localization/nn.lproj/Localizable.strings index 91add59..821c933 100644 --- a/Sora/Localization/nn.lproj/Localizable.strings +++ b/Sora/Localization/nn.lproj/Localizable.strings @@ -485,4 +485,13 @@ "Export Backup" = "Eksporter sikkerhetskopi"; "Import Backup" = "Importer sikkerhetskopi"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Merk: Denne funksjonen er fortsatt eksperimentell. Vennligst dobbeltsjekk dataene dine etter eksport/import."; -"Backup" = "Sikkerhetskopi"; \ No newline at end of file +"Backup" = "Sikkerhetskopi"; + +"Total Orphaned File Size" = "Total storleik på foreldrelause filer"; +"Total Orphaned Files" = "Totalt tal på foreldrelause filer"; + +"Orphaned Downloads" = "Foreldrelause nedlastingar"; +"No orphaned files found." = "Ingen foreldrelause filer funne."; +"Delete All Orphaned Files" = "Slett alle foreldrelause filer"; +"Delete Selected Files?" = "Slette valde filer?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Er du sikker på at du vil slette dei valde foreldrelause filene? Denne handlinga kan ikkje angrast."; \ No newline at end of file diff --git a/Sora/Localization/ru.lproj/Localizable.strings b/Sora/Localization/ru.lproj/Localizable.strings index 2bfa200..cc0c653 100644 --- a/Sora/Localization/ru.lproj/Localizable.strings +++ b/Sora/Localization/ru.lproj/Localizable.strings @@ -509,4 +509,13 @@ For episode metadata, it refers to the episode thumbnail and title, since someti "Export Backup" = "Экспорт резервной копии"; "Import Backup" = "Импорт резервной копии"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Внимание: Эта функция все еще экспериментальная. Пожалуйста, проверьте свои данные после экспорта/импорта."; -"Backup" = "Резервная копия"; \ No newline at end of file +"Backup" = "Резервная копия"; + +"Total Orphaned File Size" = "Общий размер осиротевших файлов"; +"Total Orphaned Files" = "Общее количество осиротевших файлов"; + +"Orphaned Downloads" = "Осиротевшие загрузки"; +"No orphaned files found." = "Осиротевшие файлы не найдены."; +"Delete All Orphaned Files" = "Удалить все осиротевшие файлы"; +"Delete Selected Files?" = "Удалить выбранные файлы?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Вы уверены, что хотите удалить выбранные осиротевшие файлы? Это действие необратимо."; diff --git a/Sora/Localization/sk.lproj/Localizable.strings b/Sora/Localization/sk.lproj/Localizable.strings index e0addd2..199f01e 100644 --- a/Sora/Localization/sk.lproj/Localizable.strings +++ b/Sora/Localization/sk.lproj/Localizable.strings @@ -506,3 +506,12 @@ For episode metadata, it refers to the episode thumbnail and title, since someti "Import Backup" = "Importovať zálohu"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Upozornenie: Táto funkcia je stále experimentálna. Po exporte/importe si prosím skontrolujte svoje údaje."; "Backup" = "Záloha"; + +"Total Orphaned File Size" = "Celková veľkosť osirelých súborov"; +"Total Orphaned Files" = "Celkový počet osirelých súborov"; + +"Orphaned Downloads" = "Osirelé sťahovania"; +"No orphaned files found." = "Nenašli sa žiadne osirelé súbory."; +"Delete All Orphaned Files" = "Vymazať všetky osirelé súbory"; +"Delete Selected Files?" = "Vymazať vybrané súbory?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Ste si istí, že chcete vymazať vybrané osirelé súbory? Táto akcia je nevratná."; diff --git a/Sora/Localization/sv.lproj/Localizable.strings b/Sora/Localization/sv.lproj/Localizable.strings index a9b6bbe..4ff2bfd 100644 --- a/Sora/Localization/sv.lproj/Localizable.strings +++ b/Sora/Localization/sv.lproj/Localizable.strings @@ -484,4 +484,13 @@ "Export Backup" = "Exportera säkerhetskopia"; "Import Backup" = "Importera säkerhetskopia"; "Notice: This feature is still experimental. Please double-check your data after import/export." = "Observera: Denna funktion är fortfarande experimentell. Kontrollera dina data efter export/import."; -"Backup" = "Säkerhetskopia"; \ No newline at end of file +"Backup" = "Säkerhetskopia"; + +"Total Orphaned File Size" = "Total storlek för föräldralösa filer"; +"Total Orphaned Files" = "Totalt antal föräldralösa filer"; + +"Orphaned Downloads" = "Föräldralösa nedladdningar"; +"No orphaned files found." = "Inga föräldralösa filer hittades."; +"Delete All Orphaned Files" = "Ta bort alla föräldralösa filer"; +"Delete Selected Files?" = "Ta bort valda filer?"; +"Are you sure you want to delete the selected orphaned files? This action cannot be undone." = "Är du säker på att du vill ta bort de valda föräldralösa filerna? Denna åtgärd kan inte ångras."; \ No newline at end of file diff --git a/Sora/Utlis & Misc/DownloadUtils/DownloadPersistence.swift b/Sora/Utlis & Misc/DownloadUtils/DownloadPersistence.swift index 327edec..8f81c43 100644 --- a/Sora/Utlis & Misc/DownloadUtils/DownloadPersistence.swift +++ b/Sora/Utlis & Misc/DownloadUtils/DownloadPersistence.swift @@ -43,6 +43,27 @@ enum DownloadPersistence { save(assets) } + static func orphanedFiles() -> [URL] { + let fileManager = FileManager.default + let downloadsDir = documentsDirectory + let jsonFile = downloadsDir.appendingPathComponent(jsonFileName) + let persistedAssets = load() + let referencedPaths = Set(persistedAssets.compactMap { [$0.localURL.lastPathComponent] + ($0.localSubtitleURL != nil ? [$0.localSubtitleURL!.lastPathComponent] : []) }.flatMap { $0 }) + var orphaned: [URL] = [] + do { + let files = try fileManager.contentsOfDirectory(at: downloadsDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) + for file in files { + let name = file.lastPathComponent + if name == jsonFileName { continue } + if !referencedPaths.contains(name) { + orphaned.append(file) + } + } + } catch { + } + return orphaned + } + private static func readStore() -> DiskStore { let url = documentsDirectory.appendingPathComponent(jsonFileName) guard FileManager.default.fileExists(atPath: url.path), diff --git a/Sora/Views/SettingsView/SettingsSubViews/OrphanedDownloadsView.swift b/Sora/Views/SettingsView/SettingsSubViews/OrphanedDownloadsView.swift new file mode 100644 index 0000000..6980724 --- /dev/null +++ b/Sora/Views/SettingsView/SettingsSubViews/OrphanedDownloadsView.swift @@ -0,0 +1,248 @@ +import SwiftUI +import Drops + +struct OrphanedDownloadsView: View { + @State private var orphanedFiles: [URL] = [] + @State private var selectedFiles: Set = [] + @State private var showDeleteConfirmation = false + @State private var isLoading = false + @Environment(\.presentationMode) private var presentationMode + + var body: some View { + NavigationView { + ScrollView(showsIndicators: false) { + VStack(spacing: 24) { + if isLoading { + loadingView + } else if orphanedFiles.isEmpty { + emptyStateView + } else { + orphanedFilesListView + + if !selectedFiles.isEmpty { + deleteSelectedButton + } + } + } + .padding(.vertical, 20) + .scrollViewBottomPadding() + } + .navigationTitle("Orphaned Downloads") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "xmark") + .foregroundColor(.primary) + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + if !orphanedFiles.isEmpty && !isLoading { + Button(action: { + loadOrphanedFiles() + }) { + Image(systemName: "arrow.clockwise") + .foregroundColor(.primary) + } + } else { + // Empty spacer to maintain layout when refresh button is hidden + Color.clear.frame(width: 20, height: 20) + } + } + } + } + .onAppear(perform: loadOrphanedFiles) + .alert(NSLocalizedString("Delete Selected Files?", comment: ""), isPresented: $showDeleteConfirmation) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} + Button(NSLocalizedString("Delete", comment: ""), role: .destructive) { + deleteSelectedFiles() + } + } message: { + Text(NSLocalizedString("Are you sure you want to delete the selected orphaned files? This action cannot be undone.", comment: "")) + } + } + + // MARK: - Extracted Views + + private var loadingView: some View { + VStack { + Spacer() + ProgressView() + .padding() + Text(NSLocalizedString("Loading orphaned files...", comment: "")) + .foregroundColor(.secondary) + Spacer() + } + .frame(minHeight: 300) + } + + private var emptyStateView: some View { + VStack { + Spacer() + Image(systemName: "checkmark.circle") + .font(.system(size: 50)) + .foregroundColor(.green) + .padding() + Text(NSLocalizedString("No orphaned files found", comment: "")) + .font(.headline) + .foregroundColor(.primary) + Text(NSLocalizedString("Your downloads are well-organized", comment: "")) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } + .frame(minHeight: 300) + } + + private var orphanedFilesListView: some View { + VStack(alignment: .leading, spacing: 4) { + Text(NSLocalizedString("ORPHANED FILES", comment: "")) + .font(.footnote) + .foregroundColor(.gray) + .padding(.horizontal, 20) + + orphanedFilesContainer + } + } + + private var orphanedFilesContainer: some View { + VStack(spacing: 0) { + deleteAllButton + + Divider() + .padding(.horizontal, 16) + + ForEach(orphanedFiles, id: \.self) { file in + fileRow(for: file) + + if file != orphanedFiles.last { + Divider() + .padding(.horizontal, 16) + } + } + } + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.3), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) + ) + .padding(.horizontal, 20) + } + + private var deleteAllButton: some View { + Button(action: { + selectedFiles = Set(orphanedFiles) + showDeleteConfirmation = true + }) { + HStack { + Image(systemName: "trash") + .frame(width: 24, height: 24) + .foregroundColor(.red) + + Text(NSLocalizedString("Delete All Orphaned Files", comment: "")) + .foregroundColor(.red) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + + private func fileRow(for file: URL) -> some View { + Button(action: { + if selectedFiles.contains(file) { + selectedFiles.remove(file) + } else { + selectedFiles.insert(file) + } + }) { + HStack { + Image(systemName: selectedFiles.contains(file) ? "checkmark.circle.fill" : "circle") + .frame(width: 24, height: 24) + .foregroundColor(selectedFiles.contains(file) ? .accentColor : .gray) + + VStack(alignment: .leading, spacing: 2) { + Text(file.lastPathComponent) + .foregroundColor(.primary) + .lineLimit(1) + + Text(fileSizeString(for: file)) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + + private var deleteSelectedButton: some View { + Button(action: { + showDeleteConfirmation = true + }) { + Text(String(format: NSLocalizedString("Delete Selected (%d)", comment: "Button to delete selected orphaned files with count"), selectedFiles.count)) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.red) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + } + } + + // MARK: - Helper Methods + + private func loadOrphanedFiles() { + isLoading = true + DispatchQueue.global(qos: .userInitiated).async { + let files = DownloadPersistence.orphanedFiles() + DispatchQueue.main.async { + self.orphanedFiles = files + self.selectedFiles = [] + self.isLoading = false + } + } + } + + private func fileSizeString(for url: URL) -> String { + let resourceValues = try? url.resourceValues(forKeys: [.fileSizeKey]) + let size = resourceValues?.fileSize ?? 0 + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(size)) + } + + private func deleteSelectedFiles() { + let jsonFileName = "downloads.json" + var deletedCount = 0 + for file in selectedFiles { + if file.lastPathComponent == jsonFileName { continue } + if (try? FileManager.default.removeItem(at: file)) != nil { + deletedCount += 1 + } + } + loadOrphanedFiles() + if deletedCount > 0 { + DropManager.shared.success(String(format: NSLocalizedString("%d file(s) deleted successfully", comment: "Success message for deleted orphaned files"), deletedCount)) + } + } +} \ No newline at end of file diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift index 122143b..a897682 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift @@ -159,6 +159,9 @@ struct SettingsViewDownloads: View { @State private var totalStorageSize: Int64 = 0 @State private var existingDownloadCount: Int = 0 @State private var isCalculating: Bool = false + @State private var showOrphanedDownloads = false + @State private var orphanedStorageSize: Int64 = 0 + @State private var orphanedFileCount: Int = 0 var body: some View { ScrollView(showsIndicators: false) { @@ -229,42 +232,64 @@ struct SettingsViewDownloads: View { Image(systemName: "externaldrive") .frame(width: 24, height: 24) .foregroundStyle(.primary) - - Text(String(localized: "Storage Used")) + Text(String(localized: "Total Storage Used")) .foregroundStyle(.primary) - Spacer() - if isCalculating { ProgressView() .scaleEffect(0.7) .padding(.trailing, 5) } - Text(formatFileSize(totalStorageSize)) .foregroundStyle(.secondary) } .padding(.horizontal, 16) .padding(.vertical, 12) - Divider() .padding(.horizontal, 16) - HStack { - Image(systemName: "doc.text") + Image(systemName: "doc.text" ) .frame(width: 24, height: 24) .foregroundStyle(.primary) - - Text(String(localized: "Files Downloaded")) + Text(String(localized: "Total Orphaned File Size")) .foregroundStyle(.primary) - Spacer() - - Text("\(existingDownloadCount) of \(jsController.savedAssets.count)") + Text(formatFileSize(orphanedStorageSize)) .foregroundStyle(.secondary) } .padding(.horizontal, 16) .padding(.vertical, 12) + Divider() + .padding(.horizontal, 16) + HStack { + Image(systemName: "doc.text") + .frame(width: 24, height: 24) + .foregroundStyle(.primary) + Text(String(localized: "Total Orphaned Files")) + .foregroundStyle(.primary) + Spacer() + Text("\(orphanedFileCount)") + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + Divider() + .padding(.horizontal, 16) + + Button(action: { + showOrphanedDownloads = true + }) { + HStack { + Image(systemName: "questionmark.folder") + .frame(width: 24, height: 24) + .foregroundStyle(.yellow) + Text(String(localized: "Orphaned Downloads")) + .foregroundStyle(.yellow) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } Divider() .padding(.horizontal, 16) @@ -327,31 +352,69 @@ struct SettingsViewDownloads: View { calculateTotalStorage() jsController.updateMaxConcurrentDownloads(maxConcurrentDownloads) } + .sheet(isPresented: $showOrphanedDownloads) { + OrphanedDownloadsView() + } } private func calculateTotalStorage() { - guard !jsController.savedAssets.isEmpty else { - totalStorageSize = 0 - existingDownloadCount = 0 - return - } - + let downloadsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("SoraDownloads") isCalculating = true - DownloadedAsset.clearFileSizeCache() DownloadGroup.clearFileSizeCache() - DispatchQueue.global(qos: .userInitiated).async { - let total = jsController.savedAssets.reduce(0) { $0 + $1.fileSize } - let existing = jsController.savedAssets.filter { $0.fileExists }.count - + // Calculate total size of all files in SoraDownloads + let allFiles: [URL] + do { + allFiles = try FileManager.default.contentsOfDirectory(at: downloadsDir, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey], options: .skipsHiddenFiles) + } catch { + allFiles = [] + } + var totalDiskSize: Int64 = 0 + for url in allFiles { + if let resourceValues = try? url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]), + let isDirectory = resourceValues.isDirectory, isDirectory { + totalDiskSize += calculateDirectorySize(url) + } else { + totalDiskSize += Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) + } + } + // Calculate orphaned files and their size + let orphanedFiles = DownloadPersistence.orphanedFiles() + var orphanedSize: Int64 = 0 + for url in orphanedFiles { + if let resourceValues = try? url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]), + let isDirectory = resourceValues.isDirectory, isDirectory { + orphanedSize += calculateDirectorySize(url) + } else { + orphanedSize += Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) + } + } DispatchQueue.main.async { - self.totalStorageSize = total - self.existingDownloadCount = existing + self.totalStorageSize = totalDiskSize + self.orphanedStorageSize = orphanedSize + self.orphanedFileCount = orphanedFiles.count self.isCalculating = false } } } + + private func calculateDirectorySize(_ directoryURL: URL) -> Int64 { + let fileManager = FileManager.default + var totalSize: Int64 = 0 + do { + let contents = try fileManager.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey], options: .skipsHiddenFiles) + for url in contents { + let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) + if resourceValues.isDirectory == true { + totalSize += calculateDirectorySize(url) + } else { + totalSize += Int64(resourceValues.fileSize ?? 0) + } + } + } catch {} + return totalSize + } private func clearAllDownloads(preservePersistentDownloads: Bool = false) { let assetsToDelete = jsController.savedAssets diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 5fdc754..d89dda6 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -119,6 +119,7 @@ 722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */; }; 72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7C2DC8036500A61321 /* DownloadView.swift */; }; 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; }; + 7260B66D2E32A8CB00365CDA /* OrphanedDownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7260B66C2E32A8CB00365CDA /* OrphanedDownloadsView.swift */; }; 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; }; 7273F0402E26B19700DF083D /* DownloadPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7273F03F2E26B19700DF083D /* DownloadPersistence.swift */; }; 72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */; }; @@ -238,6 +239,7 @@ 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Downloads.swift"; sourceTree = ""; }; 72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = ""; }; + 7260B66C2E32A8CB00365CDA /* OrphanedDownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrphanedDownloadsView.swift; sourceTree = ""; }; 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = ""; }; 7273F03F2E26B19700DF083D /* DownloadPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadPersistence.swift; sourceTree = ""; }; 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+Downloader.swift"; sourceTree = ""; }; @@ -580,6 +582,7 @@ 133D7C832D2BE2630075467E /* SettingsSubViews */ = { isa = PBXGroup; children = ( + 7260B66C2E32A8CB00365CDA /* OrphanedDownloadsView.swift */, 041E9D712E11D71F0025F150 /* SettingsViewBackup.swift */, 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */, 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */, @@ -1066,6 +1069,7 @@ 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */, 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */, 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */, + 7260B66D2E32A8CB00365CDA /* OrphanedDownloadsView.swift in Sources */, 0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */, 0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */, 0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */,