jellyflood/Shared/Services/DownloadTask.swift

307 lines
8.6 KiB
Swift

//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Factory
import Files
import Foundation
import Get
import JellyfinAPI
// TODO: Only move items if entire download successful
// TODO: Better state for which stage of downloading
class DownloadTask: NSObject, ObservableObject {
enum DownloadError: Error {
case notEnoughStorage
var localizedDescription: String {
switch self {
case .notEnoughStorage:
return "Not enough storage"
}
}
}
enum State {
case cancelled
case complete
case downloading(Double)
case error(Error)
case ready
}
@Injected(\.logService)
private var logger
@Injected(\.currentUserSession)
private var userSession: UserSession!
@Published
var state: State = .ready
private var downloadTask: Task<Void, Never>?
let item: BaseItemDto
var imagesFolder: URL? {
item.downloadFolder?.appendingPathComponent("Images")
}
var metadataFolder: URL? {
item.downloadFolder?.appendingPathComponent("Metadata")
}
init(item: BaseItemDto) {
self.item = item
}
func createFolder() throws {
guard let downloadFolder = item.downloadFolder else { return }
try FileManager.default.createDirectory(at: downloadFolder, withIntermediateDirectories: true)
}
func download() {
let task = Task {
deleteRootFolder()
// TODO: Look at TaskGroup for parallel calls
do {
try await downloadMedia()
} catch {
await MainActor.run {
self.state = .error(error)
Container.shared.downloadManager.reset()
}
return
}
await downloadBackdropImage()
await downloadPrimaryImage()
saveMetadata()
await MainActor.run {
self.state = .complete
}
}
self.downloadTask = task
}
func cancel() {
self.downloadTask?.cancel()
self.state = .cancelled
logger.trace("Cancelled download for: \(item.displayTitle)")
}
func deleteRootFolder() {
guard let downloadFolder = item.downloadFolder else { return }
try? FileManager.default.removeItem(at: downloadFolder)
}
func encodeMetadata() -> Data {
try! JSONEncoder().encode(item)
}
private func downloadMedia() async throws {
guard let downloadFolder = item.downloadFolder else { return }
let request = Paths.getDownload(itemID: item.id!)
let response = try await userSession.client.download(for: request, delegate: self)
let subtype = response.response.mimeSubtype
let mediaExtension = subtype == nil ? "" : ".\(subtype!)"
do {
try FileManager.default.createDirectory(at: downloadFolder, withIntermediateDirectories: true)
try FileManager.default.moveItem(
at: response.value,
to: downloadFolder.appendingPathComponent("Media\(mediaExtension)")
)
} catch {
logger.error("Error downloading media for: \(item.displayTitle) with error: \(error.localizedDescription)")
}
}
private func downloadBackdropImage() async {
guard let type = item.type else { return }
let imageURL: URL
// TODO: move to BaseItemDto
switch type {
case .movie, .series:
guard let url = item.imageSource(.backdrop, maxWidth: 600).url else { return }
imageURL = url
case .episode:
guard let url = item.imageSource(.primary, maxWidth: 600).url else { return }
imageURL = url
default:
return
}
guard let response = try? await userSession.client.download(
for: .init(url: imageURL).withResponse(URL.self),
delegate: self
) else { return }
let filename = getImageFilename(from: response, secondary: "Backdrop")
saveImage(from: response, filename: filename)
}
private func downloadPrimaryImage() async {
guard let type = item.type else { return }
let imageURL: URL
switch type {
case .movie, .series:
guard let url = item.imageSource(.primary, maxWidth: 300).url else { return }
imageURL = url
default:
return
}
guard let response = try? await userSession.client.download(
for: .init(url: imageURL).withResponse(URL.self),
delegate: self
) else { return }
let filename = getImageFilename(from: response, secondary: "Primary")
saveImage(from: response, filename: filename)
}
private func saveImage(from response: Response<URL>?, filename: String) {
guard let response, let imagesFolder else { return }
do {
try FileManager.default.createDirectory(at: imagesFolder, withIntermediateDirectories: true)
try FileManager.default.moveItem(
at: response.value,
to: imagesFolder.appendingPathComponent(filename)
)
} catch {
logger.error("Error saving image: \(error.localizedDescription)")
}
}
private func getImageFilename(from response: Response<URL>, secondary: String) -> String {
if let suggestedFilename = response.response.suggestedFilename {
return suggestedFilename
} else {
let imageExtension = response.response.mimeSubtype ?? "png"
return "\(secondary).\(imageExtension)"
}
}
private func saveMetadata() {
guard let metadataFolder else { return }
let jsonEncoder = JSONEncoder()
jsonEncoder.outputFormatting = .prettyPrinted
let itemJsonData = try! jsonEncoder.encode(item)
let itemJson = String(data: itemJsonData, encoding: .utf8)
let itemFileURL = metadataFolder.appendingPathComponent("Item.json")
do {
try FileManager.default.createDirectory(at: metadataFolder, withIntermediateDirectories: true)
try itemJson?.write(to: itemFileURL, atomically: true, encoding: .utf8)
} catch {
logger.error("Error saving item metadata: \(error.localizedDescription)")
}
}
func getImageURL(name: String) -> URL? {
do {
guard let imagesFolder else { return nil }
let images = try FileManager.default.contentsOfDirectory(atPath: imagesFolder.path)
guard let imageFilename = images.first(where: { $0.starts(with: name) }) else { return nil }
return imagesFolder.appendingPathComponent(imageFilename)
} catch {
return nil
}
}
func getMediaURL() -> URL? {
do {
guard let downloadFolder = item.downloadFolder else { return nil }
let contents = try FileManager.default.contentsOfDirectory(atPath: downloadFolder.path)
guard let mediaFilename = contents.first(where: { $0.starts(with: "Media") }) else { return nil }
return downloadFolder.appendingPathComponent(mediaFilename)
} catch {
return nil
}
}
}
// MARK: URLSessionDownloadDelegate
extension DownloadTask: URLSessionDownloadDelegate {
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
DispatchQueue.main.async {
self.state = .downloading(progress)
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {}
func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
guard let error else { return }
DispatchQueue.main.async {
self.state = .error(error)
Container.shared.downloadManager.reset()
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error else { return }
DispatchQueue.main.async {
self.state = .error(error)
Container.shared.downloadManager.reset()
}
}
}
extension DownloadTask: Identifiable {
var id: String {
item.id!
}
}