Download persistence system revamp (#225)
Some checks failed
Build and Release / Build IPA (push) Has been cancelled
Build and Release / Build Mac Catalyst (push) Has been cancelled

* Add DownloadPersistence for JSON and UserDefaults management

New DownloadPersistence utility to handle the loading, saving, and deleting of downloaded assets using a JSON file and UserDefaults. Updated JSController to utilize this new persistence layer and migration from the previous UserDefaults implementation.

* Add DownloadPersistence.swift to project structure

* Update DownloadPersistence.swift

* nah

---------

Co-authored-by: cranci1 <100066266+cranci1@users.noreply.github.com>
This commit is contained in:
realdoomsboygaming 2025-07-15 11:21:22 -05:00 committed by GitHub
parent 23ec9128f4
commit 5de39238e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 118 additions and 51 deletions

View file

@ -0,0 +1,86 @@
//
// DownloadPersistence.swift
// Sulfur
//
// Created by doomsboygaming on 15/07/25.
//
import Foundation
private var documentsDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
.appendingPathComponent("SoraDownloads")
}
private let jsonFileName = "downloads.json"
private let defaultsKey = "downloadIndex"
private struct DiskStore: Codable {
var assets: [DownloadedAsset] = []
}
enum DownloadPersistence {
static func load() -> [DownloadedAsset] {
migrateIfNeeded()
return readStore().assets
}
static func save(_ assets: [DownloadedAsset]) {
writeStore(DiskStore(assets: assets))
updateDefaultsIndex(from: assets)
}
static func upsert(_ asset: DownloadedAsset) {
var assets = load()
assets.removeAll { $0.id == asset.id }
assets.append(asset)
save(assets)
}
static func delete(id: UUID) {
var assets = load()
assets.removeAll { $0.id == id }
save(assets)
}
private static func readStore() -> DiskStore {
let url = documentsDirectory.appendingPathComponent(jsonFileName)
guard FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode(DiskStore.self, from: data)
else { return DiskStore() }
return decoded
}
private static func writeStore(_ store: DiskStore) {
try? FileManager.default.createDirectory(at: documentsDirectory,
withIntermediateDirectories: true)
let url = documentsDirectory.appendingPathComponent(jsonFileName)
guard let data = try? JSONEncoder().encode(store) else { return }
try? data.write(to: url)
}
private static func updateDefaultsIndex(from assets: [DownloadedAsset]) {
let dict = Dictionary(uniqueKeysWithValues:
assets.map { ($0.id.uuidString, $0.localURL.lastPathComponent) })
UserDefaults.standard.set(dict, forKey: defaultsKey)
}
private static var migrationDoneKey = "migrationToJSONDone"
private static func migrateIfNeeded() {
guard !UserDefaults.standard.bool(forKey: migrationDoneKey),
let oldData = UserDefaults.standard.data(forKey: "downloadedAssets") else {
return
}
do {
let oldAssets = try JSONDecoder().decode([DownloadedAsset].self, from: oldData)
save(oldAssets)
UserDefaults.standard.set(true, forKey: migrationDoneKey)
UserDefaults.standard.removeObject(forKey: "downloadedAssets")
} catch {
UserDefaults.standard.set(true, forKey: migrationDoneKey)
}
}
}

View file

@ -618,27 +618,9 @@ extension JSController {
/// Load saved assets from UserDefaults
func loadSavedAssets() {
// First, migrate any existing files from Documents to Application Support
migrateExistingFilesToPersistentStorage()
guard let data = UserDefaults.standard.data(forKey: "downloadedAssets") else {
print("No saved assets found")
JSController.hasValidatedAssets = true // Mark as validated since there's nothing to validate
return
}
do {
savedAssets = try JSONDecoder().decode([DownloadedAsset].self, from: data)
print("Loaded \(savedAssets.count) saved assets")
// Only validate once per app session to avoid excessive file checks
if !JSController.hasValidatedAssets {
print("Validating asset locations...")
validateAndUpdateAssetLocations()
JSController.hasValidatedAssets = true
}
} catch {
print("Error loading saved assets: \(error.localizedDescription)")
DispatchQueue.main.async { [weak self] in
self?.savedAssets = DownloadPersistence.load()
self?.objectWillChange.send()
}
}
@ -887,13 +869,8 @@ extension JSController {
/// Save assets to UserDefaults
func saveAssets() {
do {
let data = try JSONEncoder().encode(savedAssets)
UserDefaults.standard.set(data, forKey: "downloadedAssets")
print("Saved \(savedAssets.count) assets to UserDefaults")
} catch {
print("Error saving assets: \(error.localizedDescription)")
}
DownloadPersistence.save(savedAssets)
print("Saved \(savedAssets.count) assets to persistence")
}
/// Save the current state of downloads
@ -915,40 +892,37 @@ extension JSController {
/// Delete an asset
func deleteAsset(_ asset: DownloadedAsset) {
do {
// Check if video file exists before attempting to delete
if FileManager.default.fileExists(atPath: asset.localURL.path) {
try FileManager.default.removeItem(at: asset.localURL)
print("Deleted asset file: \(asset.localURL.path)")
} else {
print("Asset file not found at path: \(asset.localURL.path)")
}
// Also delete subtitle file if it exists
if let subtitleURL = asset.localSubtitleURL,
FileManager.default.fileExists(atPath: subtitleURL.path) {
if let subtitleURL = asset.localSubtitleURL, FileManager.default.fileExists(atPath: subtitleURL.path) {
try FileManager.default.removeItem(at: subtitleURL)
print("Deleted subtitle file: \(subtitleURL.path)")
} else if asset.localSubtitleURL != nil {
print("Subtitle file not found at saved path, but reference existed")
} else {
if let downloadDir = getPersistentDownloadDirectory() {
let assetID = asset.id.uuidString
let subtitleExtensions = ["vtt", "srt", "webvtt"]
for ext in subtitleExtensions {
let candidate = downloadDir.appendingPathComponent("subtitle-\(assetID).\(ext)")
if FileManager.default.fileExists(atPath: candidate.path) {
try? FileManager.default.removeItem(at: candidate)
}
}
}
}
DownloadPersistence.delete(id: asset.id)
DispatchQueue.main.async { [weak self] in
self?.savedAssets = DownloadPersistence.load()
self?.objectWillChange.send()
}
// Remove from saved assets regardless of whether files were found
savedAssets.removeAll { $0.id == asset.id }
saveAssets()
print("Removed asset from library: \(asset.name)")
// Notify observers that an asset was deleted (cache clearing needed)
postDownloadNotification(.deleted)
} catch {
print("Error deleting asset: \(error.localizedDescription)")
}
}
/// Remove an asset from the library without deleting the file
func removeAssetFromLibrary(_ asset: DownloadedAsset) {
// Only remove the entry from savedAssets
savedAssets.removeAll { $0.id == asset.id }
saveAssets()
DownloadPersistence.delete(id: asset.id)
print("Removed asset from library (file preserved): \(asset.name)")
// Notify observers that the library changed (cache clearing needed)
@ -1214,8 +1188,11 @@ extension JSController: AVAssetDownloadDelegate {
)
// Add to saved assets and save
savedAssets.append(newAsset)
saveAssets()
DownloadPersistence.upsert(newAsset)
DispatchQueue.main.async { [weak self] in
self?.savedAssets = DownloadPersistence.load()
self?.objectWillChange.send()
}
// If there's a subtitle URL, download it now that the video is saved
if let subtitleURL = download.subtitleURL {

View file

@ -120,6 +120,7 @@
72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7C2DC8036500A61321 /* DownloadView.swift */; };
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.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 */; };
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
/* End PBXBuildFile section */
@ -238,6 +239,7 @@
72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = "<group>"; };
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = "<group>"; };
7273F03F2E26B19700DF083D /* DownloadPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadPersistence.swift; sourceTree = "<group>"; };
72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+Downloader.swift"; sourceTree = "<group>"; };
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -852,6 +854,7 @@
72443C832DC8046500A61321 /* DownloadUtils */ = {
isa = PBXGroup;
children = (
7273F03F2E26B19700DF083D /* DownloadPersistence.swift */,
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */,
131270162DC13A010093AA9C /* DownloadManager.swift */,
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */,
@ -1051,6 +1054,7 @@
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
7273F0402E26B19700DF083D /* DownloadPersistence.swift in Sources */,
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */,
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */,