Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
107 changes: 107 additions & 0 deletions Sora/Utlis & Misc/DownloadUtils/DownloadPersistence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// DownloadPersistence.swift
// Sulfur
//
// Created by doomsboygaming on 15/07/25.
//

import Foundation

/// Where the master JSON lives
private var documentsDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first!
.appendingPathComponent("SoraDownloads")
}

/// Master JSON file name
private let jsonFileName = "downloads.json"

/// Light index in UserDefaults (UUID → file name, for instant look-ups)
private let defaultsKey = "downloadIndex"

/// Root object that is written to JSON
private struct DiskStore: Codable {
var assets: [DownloadedAsset] = []
}

/// Singleton façade
enum DownloadPersistence {

// MARK: - Public API

/// Loads the entire catalogue
static func load() -> [DownloadedAsset] {
migrateIfNeeded()
return readStore().assets
}

/// Saves the entire catalogue
static func save(_ assets: [DownloadedAsset]) {
writeStore(DiskStore(assets: assets))
updateDefaultsIndex(from: assets)
}

/// Adds or replaces one asset
static func upsert(_ asset: DownloadedAsset) {
var assets = load()
assets.removeAll { $0.id == asset.id }
assets.append(asset)
save(assets)
}

/// Deletes one asset
static func delete(id: UUID) {
var assets = load()
assets.removeAll { $0.id == id }
save(assets)
}

// MARK: - Internal helpers

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)
}

/// Keeps UserDefaults in sync: [UUID → file name]
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)
}

// MARK: - One-time migration from old UserDefaults store

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)
// Remove old key to avoid bloat
UserDefaults.standard.removeObject(forKey: "downloadedAssets")
} catch {
// Couldn’t decode – ignore and start fresh
UserDefaults.standard.set(true, forKey: migrationDoneKey)
}
}
}
79 changes: 28 additions & 51 deletions Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions Sulfur.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -852,6 +854,7 @@
72443C832DC8046500A61321 /* DownloadUtils */ = {
isa = PBXGroup;
children = (
7273F03F2E26B19700DF083D /* DownloadPersistence.swift */,
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */,
131270162DC13A010093AA9C /* DownloadManager.swift */,
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
Loading