215 lines
7.0 KiB
Swift
215 lines
7.0 KiB
Swift
//
|
|
// AttachmentViewModel+Upload.swift
|
|
//
|
|
//
|
|
// Created by MainasuK on 2021-11-26.
|
|
//
|
|
|
|
import UIKit
|
|
import UniformTypeIdentifiers
|
|
import MastodonCore
|
|
import MastodonSDK
|
|
|
|
// objc.io
|
|
// ref: https://talk.objc.io/episodes/S01E269-swift-concurrency-async-sequences-part-1
|
|
struct Chunked<Base: AsyncSequence>: AsyncSequence where Base.Element == UInt8 {
|
|
var base: Base
|
|
var chunkSize: Int = 1 * 1024 * 1024 // 1 MiB
|
|
typealias Element = Data
|
|
|
|
struct AsyncIterator: AsyncIteratorProtocol {
|
|
var base: Base.AsyncIterator
|
|
var chunkSize: Int
|
|
|
|
mutating func next() async throws -> Data? {
|
|
var result = Data()
|
|
while let element = try await base.next() {
|
|
result.append(element)
|
|
if result.count == chunkSize { return result }
|
|
}
|
|
return result.isEmpty ? nil : result
|
|
}
|
|
}
|
|
|
|
func makeAsyncIterator() -> AsyncIterator {
|
|
AsyncIterator(base: base.makeAsyncIterator(), chunkSize: chunkSize)
|
|
}
|
|
}
|
|
|
|
extension AsyncSequence where Element == UInt8 {
|
|
var chunked: Chunked<Self> {
|
|
Chunked(base: self)
|
|
}
|
|
}
|
|
|
|
extension Data {
|
|
fileprivate func chunks(size: Int) -> [Data] {
|
|
return stride(from: 0, to: count, by: size).map {
|
|
Data(self[$0..<Swift.min(count, $0 + size)])
|
|
}
|
|
}
|
|
}
|
|
|
|
extension AttachmentViewModel {
|
|
public enum UploadState {
|
|
case none
|
|
case compressing
|
|
case ready
|
|
case uploading
|
|
case fail
|
|
case finish
|
|
}
|
|
|
|
struct UploadContext {
|
|
let apiService: APIService
|
|
let authContext: AuthContext
|
|
}
|
|
|
|
public enum UploadResult {
|
|
case uploadedMastodonAttachment(Mastodon.Entity.Attachment)
|
|
case exists
|
|
}
|
|
}
|
|
|
|
extension AttachmentViewModel {
|
|
@MainActor
|
|
func upload(isRetry: Bool = false) async throws {
|
|
do {
|
|
let result = try await upload(
|
|
context: .init(
|
|
apiService: self.api,
|
|
authContext: self.authContext
|
|
),
|
|
isRetry: isRetry
|
|
)
|
|
update(uploadResult: result)
|
|
} catch {
|
|
self.error = error
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func upload(context: UploadContext, isRetry: Bool) async throws -> UploadResult {
|
|
if isRetry {
|
|
guard uploadState == .fail else { throw AppError.badRequest }
|
|
self.error = nil
|
|
self.fractionCompleted = 0
|
|
} else {
|
|
guard uploadState == .ready else { throw AppError.badRequest }
|
|
}
|
|
do {
|
|
update(uploadState: .uploading)
|
|
let result = try await uploadMastodonMedia(
|
|
context: context
|
|
)
|
|
update(uploadState: .finish)
|
|
return result
|
|
} catch {
|
|
update(uploadState: .fail)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MainActor is required here to trigger stream upload task
|
|
@MainActor
|
|
private func uploadMastodonMedia(
|
|
context: UploadContext
|
|
) async throws -> UploadResult {
|
|
guard let output = self.output else {
|
|
throw AppError.badRequest
|
|
}
|
|
|
|
let attachment = output.asAttachment
|
|
|
|
let query = Mastodon.API.Media.UploadMediaQuery(
|
|
file: attachment,
|
|
thumbnail: nil,
|
|
description: caption.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
focus: nil
|
|
)
|
|
|
|
// upload + N * check upload
|
|
// upload : check = 9 : 1
|
|
let uploadTaskCount: Int64 = 540
|
|
let checkUploadTaskCount: Int64 = 1
|
|
let checkUploadTaskRetryLimit: Int64 = 60
|
|
|
|
progress.totalUnitCount = uploadTaskCount + checkUploadTaskCount * checkUploadTaskRetryLimit
|
|
progress.completedUnitCount = 0
|
|
|
|
let attachmentUploadResponse: Mastodon.Response.Content<Mastodon.Entity.Attachment> = try await {
|
|
do {
|
|
progress.addChild(query.progress, withPendingUnitCount: uploadTaskCount)
|
|
return try await context.apiService.uploadMedia(
|
|
domain: context.authContext.mastodonAuthenticationBox.domain,
|
|
query: query,
|
|
mastodonAuthenticationBox: context.authContext.mastodonAuthenticationBox,
|
|
needsFallback: false
|
|
).singleOutput()
|
|
} catch {
|
|
// check needs fallback
|
|
guard let apiError = error as? Mastodon.API.Error,
|
|
apiError.httpResponseStatus == .notFound
|
|
else { throw error }
|
|
|
|
progress.addChild(query.progress, withPendingUnitCount: uploadTaskCount)
|
|
return try await context.apiService.uploadMedia(
|
|
domain: context.authContext.mastodonAuthenticationBox.domain,
|
|
query: query,
|
|
mastodonAuthenticationBox: context.authContext.mastodonAuthenticationBox,
|
|
needsFallback: true
|
|
).singleOutput()
|
|
}
|
|
}()
|
|
|
|
// check needs wait processing (until get the `url`)
|
|
if attachmentUploadResponse.statusCode == 202 {
|
|
// note:
|
|
// the Mastodon server append the attachments in order by upload time
|
|
// can not upload parallels
|
|
let waitProcessRetryLimit = checkUploadTaskRetryLimit
|
|
var waitProcessRetryCount: Int64 = 0
|
|
|
|
repeat {
|
|
defer {
|
|
// make sure always count + 1
|
|
waitProcessRetryCount += checkUploadTaskCount
|
|
}
|
|
|
|
let attachmentStatusResponse = try await context.apiService.getMedia(
|
|
attachmentID: attachmentUploadResponse.value.id,
|
|
mastodonAuthenticationBox: context.authContext.mastodonAuthenticationBox
|
|
).singleOutput()
|
|
progress.completedUnitCount += checkUploadTaskCount
|
|
|
|
if attachmentStatusResponse.value.url != nil {
|
|
// escape here
|
|
progress.completedUnitCount = progress.totalUnitCount
|
|
return .uploadedMastodonAttachment(attachmentStatusResponse.value)
|
|
|
|
} else {
|
|
try await Task.sleep(nanoseconds: 1_000_000_000 * 3) // 3s
|
|
}
|
|
} while waitProcessRetryCount < waitProcessRetryLimit
|
|
|
|
throw AppError.badRequest
|
|
} else {
|
|
return .uploadedMastodonAttachment(attachmentUploadResponse.value)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension AttachmentViewModel.Output {
|
|
var asAttachment: Mastodon.Query.MediaAttachment {
|
|
switch self {
|
|
case .image(let data, let kind):
|
|
switch kind {
|
|
case .png: return .png(data)
|
|
case .jpg: return .jpeg(data)
|
|
}
|
|
case .video(let url, _):
|
|
return .other(url, fileExtension: url.pathExtension, mimeType: "video/mp4")
|
|
}
|
|
}
|
|
}
|