mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-09 05:18:40 +02:00
feat: implement download queue on iOS
This commit is contained in:
@@ -28,32 +28,70 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
|
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
|
||||||
private var downloadItemProgress = [String: DownloadItem]()
|
private var downloadItemProgress = [String: DownloadItem]()
|
||||||
private var monitoringProgressTimer: Timer?
|
private var monitoringProgressTimer: Timer?
|
||||||
|
|
||||||
|
// Download queue management
|
||||||
|
private let downloadQueueLock = NSLock()
|
||||||
|
private var pendingDownloadTasks: [DownloadItemPartTask] = []
|
||||||
|
private var activeDownloadTasks: Set<String> = [] // Track active task IDs
|
||||||
|
private let maxConcurrentDownloads = 3
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Download Queue Management
|
||||||
|
|
||||||
|
private func startNextDownloadInQueue() {
|
||||||
|
downloadQueueLock.lock()
|
||||||
|
defer { downloadQueueLock.unlock() }
|
||||||
|
|
||||||
|
// Start downloads up to the max concurrent limit
|
||||||
|
while activeDownloadTasks.count < maxConcurrentDownloads && !pendingDownloadTasks.isEmpty {
|
||||||
|
let nextTask = pendingDownloadTasks.removeFirst()
|
||||||
|
activeDownloadTasks.insert(nextTask.partId)
|
||||||
|
AbsLogger.info(message: "Starting download for \(nextTask.filename) (\(activeDownloadTasks.count)/\(maxConcurrentDownloads) active, \(pendingDownloadTasks.count) pending)")
|
||||||
|
nextTask.task.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markDownloadTaskCompleted(_ taskId: String) {
|
||||||
|
downloadQueueLock.lock()
|
||||||
|
activeDownloadTasks.remove(taskId)
|
||||||
|
downloadQueueLock.unlock()
|
||||||
|
|
||||||
|
// Try to start the next download
|
||||||
|
startNextDownloadInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Progress handling
|
// MARK: - Progress handling
|
||||||
|
|
||||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||||
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||||
let realm = try Realm()
|
let realm = try Realm()
|
||||||
|
let partId = downloadItemPart.id
|
||||||
|
|
||||||
|
// Get fresh reference to the object in this realm
|
||||||
|
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartNotFound
|
||||||
|
}
|
||||||
|
|
||||||
try realm.write {
|
try realm.write {
|
||||||
downloadItemPart.bytesDownloaded = downloadItemPart.fileSize
|
liveDownloadItemPart.bytesDownloaded = liveDownloadItemPart.fileSize
|
||||||
downloadItemPart.progress = 100
|
liveDownloadItemPart.progress = 100
|
||||||
downloadItemPart.completed = true
|
liveDownloadItemPart.completed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Move the downloaded file into place
|
// Move the downloaded file into place
|
||||||
guard let destinationUrl = downloadItemPart.destinationURL else {
|
guard let destinationUrl = liveDownloadItemPart.destinationURL else {
|
||||||
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
||||||
}
|
}
|
||||||
try? FileManager.default.removeItem(at: destinationUrl)
|
try? FileManager.default.removeItem(at: destinationUrl)
|
||||||
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
||||||
try realm.write {
|
try realm.write {
|
||||||
downloadItemPart.moved = true
|
liveDownloadItemPart.moved = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
try realm.write {
|
try realm.write {
|
||||||
downloadItemPart.failed = true
|
liveDownloadItemPart.failed = true
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -63,13 +101,26 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
try Realm().write {
|
let realm = try Realm()
|
||||||
downloadItemPart.completed = true
|
let partId = downloadItemPart.id
|
||||||
downloadItemPart.failed = true
|
|
||||||
|
// Get fresh reference to the object in this realm
|
||||||
|
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
try realm.write {
|
||||||
|
liveDownloadItemPart.completed = true
|
||||||
|
liveDownloadItemPart.failed = true
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark this task as completed and start the next download in queue
|
||||||
|
if let taskId = task.taskDescription {
|
||||||
|
markDownloadTaskCompleted(taskId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||||
@@ -79,9 +130,17 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
// Only update the progress if we received accurate progress data
|
// Only update the progress if we received accurate progress data
|
||||||
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
||||||
try Realm().write {
|
let realm = try Realm()
|
||||||
downloadItemPart.bytesDownloaded = Double(totalBytesWritten)
|
let partId = downloadItemPart.id
|
||||||
downloadItemPart.progress = percentDownloaded
|
|
||||||
|
// Get fresh reference to the object in this realm
|
||||||
|
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
try realm.write {
|
||||||
|
liveDownloadItemPart.bytesDownloaded = Double(totalBytesWritten)
|
||||||
|
liveDownloadItemPart.progress = percentDownloaded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,32 +366,37 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
// Persist in the database before status start coming in
|
// Persist in the database before status start coming in
|
||||||
try Database.shared.saveDownloadItem(downloadItem)
|
try Database.shared.saveDownloadItem(downloadItem)
|
||||||
|
|
||||||
// Start all the downloads
|
// Add all tasks to the download queue
|
||||||
for task in tasks {
|
downloadQueueLock.lock()
|
||||||
task.task.resume()
|
pendingDownloadTasks.append(contentsOf: tasks)
|
||||||
}
|
downloadQueueLock.unlock()
|
||||||
|
|
||||||
|
AbsLogger.info(message: "Added \(tasks.count) tasks to download queue. Starting downloads...")
|
||||||
|
|
||||||
|
// Start downloading (up to maxConcurrentDownloads at a time)
|
||||||
|
startNextDownloadInQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLibraryItemTrackDownload(downloadItemId: String, item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
private func startLibraryItemTrackDownload(downloadItemId: String, item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
||||||
AbsLogger.info(message: "TRACK \(track.contentUrl!)")
|
AbsLogger.info(message: "TRACK \(track.contentUrl!)")
|
||||||
|
|
||||||
// If we don't name metadata, then we can't proceed
|
// If we don't name metadata, then we can't proceed
|
||||||
guard let filename = track.metadata?.filename else {
|
guard let filename = track.metadata?.filename else {
|
||||||
throw LibraryItemDownloadError.noMetadata
|
throw LibraryItemDownloadError.noMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
let serverUrl = urlForTrack(item: item, track: track)
|
let serverUrl = urlForTrack(item: item, track: track)
|
||||||
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||||
let localUrl = "\(itemDirectory)/\(filename)"
|
let localUrl = "\(itemDirectory)/\(filename)"
|
||||||
|
|
||||||
let task = session.downloadTask(with: serverUrl)
|
let task = session.downloadTask(with: serverUrl)
|
||||||
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode, ebookFile: nil, size: track.metadata?.size ?? 0)
|
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode, ebookFile: nil, size: track.metadata?.size ?? 0)
|
||||||
|
|
||||||
// Store the id on the task so the download item can be pulled from the database later
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
task.taskDescription = part.id
|
task.taskDescription = part.id
|
||||||
|
|
||||||
return DownloadItemPartTask(part: part, task: task)
|
return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLibraryItemEbookDownload(downloadItemId: String, item: LibraryItem, ebookFile: EBookFile) throws -> DownloadItemPartTask {
|
private func startLibraryItemEbookDownload(downloadItemId: String, item: LibraryItem, ebookFile: EBookFile) throws -> DownloadItemPartTask {
|
||||||
@@ -340,14 +404,14 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
let serverPath = "/api/items/\(item.id)/file/\(ebookFile.ino)/download"
|
let serverPath = "/api/items/\(item.id)/file/\(ebookFile.ino)/download"
|
||||||
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||||
let localUrl = "\(itemDirectory)/\(filename)"
|
let localUrl = "\(itemDirectory)/\(filename)"
|
||||||
|
|
||||||
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: filename, serverPath: serverPath, audioTrack: nil, episode: nil, ebookFile: ebookFile, size: ebookFile.metadata?.size ?? 0)
|
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: filename, serverPath: serverPath, audioTrack: nil, episode: nil, ebookFile: ebookFile, size: ebookFile.metadata?.size ?? 0)
|
||||||
let task = session.downloadTask(with: part.downloadURL!)
|
let task = session.downloadTask(with: part.downloadURL!)
|
||||||
|
|
||||||
// Store the id on the task so the download item can be pulled from the database later
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
task.taskDescription = part.id
|
task.taskDescription = part.id
|
||||||
|
|
||||||
return DownloadItemPartTask(part: part, task: task)
|
return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLibraryItemCoverDownload(downloadItemId: String, item: LibraryItem) throws -> DownloadItemPartTask {
|
private func startLibraryItemCoverDownload(downloadItemId: String, item: LibraryItem) throws -> DownloadItemPartTask {
|
||||||
@@ -355,19 +419,19 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
let serverPath = "/api/items/\(item.id)/cover"
|
let serverPath = "/api/items/\(item.id)/cover"
|
||||||
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||||
let localUrl = "\(itemDirectory)/\(filename)"
|
let localUrl = "\(itemDirectory)/\(filename)"
|
||||||
|
|
||||||
// Find library file to get cover size
|
// Find library file to get cover size
|
||||||
let coverLibraryFile = item.libraryFiles.first(where: {
|
let coverLibraryFile = item.libraryFiles.first(where: {
|
||||||
$0.metadata?.path == item.media?.coverPath
|
$0.metadata?.path == item.media?.coverPath
|
||||||
})
|
})
|
||||||
|
|
||||||
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil, ebookFile: nil, size: coverLibraryFile?.metadata?.size ?? 0)
|
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil, ebookFile: nil, size: coverLibraryFile?.metadata?.size ?? 0)
|
||||||
let task = session.downloadTask(with: part.downloadURL!)
|
let task = session.downloadTask(with: part.downloadURL!)
|
||||||
|
|
||||||
// Store the id on the task so the download item can be pulled from the database later
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
task.taskDescription = part.id
|
task.taskDescription = part.id
|
||||||
|
|
||||||
return DownloadItemPartTask(part: part, task: task)
|
return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
||||||
@@ -431,6 +495,8 @@ typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadIte
|
|||||||
struct DownloadItemPartTask {
|
struct DownloadItemPartTask {
|
||||||
let part: DownloadItemPart
|
let part: DownloadItemPart
|
||||||
let task: URLSessionDownloadTask
|
let task: URLSessionDownloadTask
|
||||||
|
let partId: String // Cache the ID to avoid cross-thread Realm access
|
||||||
|
let filename: String // Cache the filename to avoid cross-thread Realm access
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LibraryItemDownloadError: String, Error {
|
enum LibraryItemDownloadError: String, Error {
|
||||||
|
|||||||
Reference in New Issue
Block a user