Skip to content

Add MediaUploadService for uploading files to Media Library #24655

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: trunk
Choose a base branch
from
Open
Changes from all 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
259 changes: 259 additions & 0 deletions WordPress/Classes/Services/MediaUploadService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import Foundation
import WordPressData
import WordPressCore
import WordPressAPI
import WordPressAPIInternal

actor MediaUploadService {
private let coreDataStack: CoreDataStackSwift
private let blog: TaggedManagedObjectID<Blog>
private let client: WordPressClient

init(coreDataStack: CoreDataStackSwift, blog: TaggedManagedObjectID<Blog>, client: WordPressClient) {
self.coreDataStack = coreDataStack
self.blog = blog
self.client = client
}

/// Uploads an asset to the site media library.
///
/// - Parameters:
/// - asset: The asset to upload.
/// - progress: A progress object to track the upload progress.
/// - Returns: The saved Media instance.
func uploadToMediaLibrary(asset: ExportableAsset, fulfilling progress: Progress? = nil) async throws -> TaggedManagedObjectID<Media> {
precondition(progress == nil || progress!.totalUnitCount > 0)

let overallProgress = progress ?? Progress.discreteProgress(totalUnitCount: 100)
overallProgress.completedUnitCount = 0

let export = try await exportAsset(asset, parentProgress: overallProgress)

let uploadingProgress = Progress.discreteProgress(totalUnitCount: 100)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like It might be missing a call to create a placeholder Media object to be shown in the "Media" list during the upload.

overallProgress.addChild(uploadingProgress, withPendingUnitCount: Int64((1.0 - overallProgress.fractionCompleted) * Double(overallProgress.totalUnitCount)))
let uploaded = try await client.api.uploadMedia(
params: MediaCreateParams(from: export),
fromLocalFileURL: export.url,
fulfilling: uploadingProgress
).data

let media = try await coreDataStack.performAndSave { [blogID = blog] context in
let blog = try context.existingObject(with: blogID)
let media = Media.existingMediaWith(mediaID: .init(value: uploaded.id), inBlog: blog)
?? Media.makeMedia(blog: blog)

self.configureMedia(media, withExport: export)
self.updateMedia(media, with: uploaded)

return TaggedManagedObjectID(media)
}

overallProgress.completedUnitCount = overallProgress.totalUnitCount
return media
}
}

// MARK: - Export

private extension MediaUploadService {

func exportAsset(_ exportable: ExportableAsset, parentProgress: Progress) async throws -> MediaExport {
let options = try await coreDataStack.performQuery { [blogID = blog] context in
let blog = try context.existingObject(with: blogID)
let allowableFileExtensions = blog.allowedFileTypes as? Set<String> ?? []
return self.makeExportOptions(for: blog, allowableFileExtensions: allowableFileExtensions)
}

guard let exporter = self.makeExporter(for: exportable, options: options) else {
preconditionFailure("No exporter found for \(exportable)")
}

return try await withCheckedThrowingContinuation { continuation in
let progress = exporter.export(
onCompletion: { export in
continuation.resume(returning: export)
},
onError: { error in
DDLogError("Error occurred exporting asset: \(error)")
continuation.resume(throwing: error)
}
)
// The "export" part covers the initial 10% of the overall progress.
parentProgress.addChild(progress, withPendingUnitCount: progress.totalUnitCount / 10)
}
}

func makeExporter(for exportable: ExportableAsset, options: ExportOptions) -> MediaExporter? {
switch exportable {
case let provider as NSItemProvider:
let exporter = ItemProviderMediaExporter(provider: provider)
exporter.imageOptions = options.imageOptions
exporter.videoOptions = options.videoOptions
return exporter
case let image as UIImage:
let exporter = MediaImageExporter(image: image, filename: nil)
exporter.options = options.imageOptions
return exporter
case let url as URL:
let exporter = MediaURLExporter(url: url)
exporter.imageOptions = options.imageOptions
exporter.videoOptions = options.videoOptions
exporter.urlOptions = options.urlOptions
return exporter
case let stockPhotosMedia as StockPhotosMedia:
let exporter = MediaExternalExporter(externalAsset: stockPhotosMedia)
return exporter
case let tenorMedia as TenorMedia:
let exporter = MediaExternalExporter(externalAsset: tenorMedia)
return exporter
default:
return nil
}
}

func configureMedia(_ media: Media, withExport export: MediaExport) {
media.absoluteLocalURL = export.url
media.filename = export.url.lastPathComponent
media.mediaType = (export.url as NSURL).assetMediaType

if let fileSize = export.fileSize {
media.filesize = fileSize as NSNumber
}

if let width = export.width {
media.width = width as NSNumber
}

if let height = export.height {
media.height = height as NSNumber
}

if let duration = export.duration {
media.length = duration as NSNumber
}

if let caption = export.caption {
media.caption = caption
}
}

struct ExportOptions {
var imageOptions: MediaImageExporter.Options
var videoOptions: MediaVideoExporter.Options
var urlOptions: MediaURLExporter.Options
var allowableFileExtensions: Set<String>
}

func makeExportOptions(for blog: Blog, allowableFileExtensions: Set<String>) -> ExportOptions {
ExportOptions(imageOptions: exporterImageOptions,
videoOptions: makeExporterVideoOptions(for: blog),
urlOptions: exporterURLOptions(allowableFileExtensions: allowableFileExtensions),
allowableFileExtensions: allowableFileExtensions)
}

var exporterImageOptions: MediaImageExporter.Options {
var options = MediaImageExporter.Options()
options.maximumImageSize = self.exporterMaximumImageSize()
options.stripsGeoLocationIfNeeded = MediaSettings().removeLocationSetting
options.imageCompressionQuality = MediaSettings().imageQualityForUpload.doubleValue
return options
}

func makeExporterVideoOptions(for blog: Blog) -> MediaVideoExporter.Options {
var options = MediaVideoExporter.Options()
options.stripsGeoLocationIfNeeded = MediaSettings().removeLocationSetting
options.exportPreset = MediaSettings().maxVideoSizeSetting.videoPreset
options.durationLimit = blog.videoDurationLimit
return options
}

func exporterURLOptions(allowableFileExtensions: Set<String>) -> MediaURLExporter.Options {
var options = MediaURLExporter.Options()
options.allowableFileExtensions = allowableFileExtensions
options.stripsGeoLocationIfNeeded = MediaSettings().removeLocationSetting
return options
}

/// Helper method to return an optional value for a valid MediaSettings max image upload size.
///
/// - Note: Eventually we'll rewrite MediaSettings.imageSizeForUpload to do this for us, but want to leave
/// that class alone while implementing MediaExportService.
///
func exporterMaximumImageSize() -> CGFloat? {
let maxUploadSize = MediaSettings().imageSizeForUpload
if maxUploadSize < Int.max {
return CGFloat(maxUploadSize)
}
return nil
}

func updateMedia(_ media: Media, with remote: MediaWithEditContext) {
media.mediaID = NSNumber(value: remote.id)
media.remoteURL = remote.sourceUrl
media.creationDate = remote.dateGmt
media.postID = remote.postId.map { NSNumber(value: $0) }
media.title = remote.title.raw
media.caption = remote.caption.raw
media.desc = remote.description.raw
media.alt = remote.altText

if let url = URL(string: remote.sourceUrl) {
media.filename = url.lastPathComponent
media.setMediaType(forFilenameExtension: url.pathExtension)
}

if case let .object(mediaDetails) = remote.mediaDetails {
if case let .int(width) = mediaDetails["width"] {
media.width = NSNumber(value: width)
}
if case let .int(height) = mediaDetails["height"] {
media.height = NSNumber(value: height)
}
if case let .int(length) = mediaDetails["length"] {
media.length = NSNumber(value: length)
}
if case let .string(file) = mediaDetails["file"] {
media.filename = file
}

// Extract different sizes
if case let .object(sizes) = mediaDetails["sizes"] {
if case let .object(medium) = sizes["medium"],
case let .string(url) = medium["source_url"] {
media.remoteMediumURL = url
}
if case let .object(large) = sizes["large"],
case let .string(url) = large["source_url"] {
media.remoteLargeURL = url
}
if case let .object(thumbnail) = sizes["thumbnail"],
case let .string(url) = thumbnail["source_url"] {
media.remoteThumbnailURL = url
}
}
}

media.remoteStatus = .sync
media.error = nil
}
}

private extension MediaCreateParams {
init(from export: MediaExport) {
self.init(
date: nil,
dateGmt: nil,
slug: nil,
status: nil,
title: export.url.lastPathComponent, // TODO: Add a `filename` property to `MediaExport`.
author: nil,
commentStatus: nil,
pingStatus: nil,
template: nil,
altText: nil,
caption: export.caption,
description: nil,
postId: nil
)
}
}