Skip to content

Commit 59ac2f9

Browse files
committed
Add MediaUploadService for uploading files to Media Library
1 parent ccb9800 commit 59ac2f9

File tree

1 file changed

+259
-0
lines changed

1 file changed

+259
-0
lines changed
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import Foundation
2+
import WordPressData
3+
import WordPressCore
4+
import WordPressAPI
5+
import WordPressAPIInternal
6+
7+
actor MediaUploadService {
8+
private let coreDataStack: CoreDataStackSwift
9+
private let blog: TaggedManagedObjectID<Blog>
10+
private let client: WordPressClient
11+
12+
init(coreDataStack: CoreDataStackSwift, blog: TaggedManagedObjectID<Blog>, client: WordPressClient) {
13+
self.coreDataStack = coreDataStack
14+
self.blog = blog
15+
self.client = client
16+
}
17+
18+
/// Uploads an asset to the site media library.
19+
///
20+
/// - Parameters:
21+
/// - asset: The asset to upload.
22+
/// - progress: A progress object to track the upload progress.
23+
/// - Returns: The saved Media instance.
24+
func uploadToMediaLibrary(asset: ExportableAsset, fulfilling progress: Progress? = nil) async throws -> TaggedManagedObjectID<Media> {
25+
precondition(progress == nil || progress!.totalUnitCount > 0)
26+
27+
let overallProgress = progress ?? Progress.discreteProgress(totalUnitCount: 100)
28+
overallProgress.completedUnitCount = 0
29+
30+
let export = try await exportAsset(asset, parentProgress: overallProgress)
31+
32+
let uploadingProgress = Progress.discreteProgress(totalUnitCount: 100)
33+
overallProgress.addChild(uploadingProgress, withPendingUnitCount: Int64((1.0 - overallProgress.fractionCompleted) * Double(overallProgress.totalUnitCount)))
34+
let uploaded = try await client.api.uploadMedia(
35+
params: MediaCreateParams(from: export),
36+
fromLocalFileURL: export.url,
37+
fulfilling: uploadingProgress
38+
).data
39+
40+
let media = try await coreDataStack.performAndSave { [blogID = blog] context in
41+
let blog = try context.existingObject(with: blogID)
42+
let media = Media.existingMediaWith(mediaID: .init(value: uploaded.id), inBlog: blog)
43+
?? Media.makeMedia(blog: blog)
44+
45+
self.configureMedia(media, withExport: export)
46+
self.updateMedia(media, with: uploaded)
47+
48+
return TaggedManagedObjectID(media)
49+
}
50+
51+
overallProgress.completedUnitCount = overallProgress.totalUnitCount
52+
return media
53+
}
54+
}
55+
56+
// MARK: - Export
57+
58+
private extension MediaUploadService {
59+
60+
func exportAsset(_ exportable: ExportableAsset, parentProgress: Progress) async throws -> MediaExport {
61+
let options = try await coreDataStack.performQuery { [blogID = blog] context in
62+
let blog = try context.existingObject(with: blogID)
63+
let allowableFileExtensions = blog.allowedFileTypes as? Set<String> ?? []
64+
return self.makeExportOptions(for: blog, allowableFileExtensions: allowableFileExtensions)
65+
}
66+
67+
guard let exporter = self.makeExporter(for: exportable, options: options) else {
68+
preconditionFailure("No exporter found for \(exportable)")
69+
}
70+
71+
return try await withCheckedThrowingContinuation { continuation in
72+
let progress = exporter.export(
73+
onCompletion: { export in
74+
continuation.resume(returning: export)
75+
},
76+
onError: { error in
77+
DDLogError("Error occurred exporting asset: \(error)")
78+
continuation.resume(throwing: error)
79+
}
80+
)
81+
// The "export" part covers the initial 10% of the overall progress.
82+
parentProgress.addChild(progress, withPendingUnitCount: progress.totalUnitCount / 10)
83+
}
84+
}
85+
86+
func makeExporter(for exportable: ExportableAsset, options: ExportOptions) -> MediaExporter? {
87+
switch exportable {
88+
case let provider as NSItemProvider:
89+
let exporter = ItemProviderMediaExporter(provider: provider)
90+
exporter.imageOptions = options.imageOptions
91+
exporter.videoOptions = options.videoOptions
92+
return exporter
93+
case let image as UIImage:
94+
let exporter = MediaImageExporter(image: image, filename: nil)
95+
exporter.options = options.imageOptions
96+
return exporter
97+
case let url as URL:
98+
let exporter = MediaURLExporter(url: url)
99+
exporter.imageOptions = options.imageOptions
100+
exporter.videoOptions = options.videoOptions
101+
exporter.urlOptions = options.urlOptions
102+
return exporter
103+
case let stockPhotosMedia as StockPhotosMedia:
104+
let exporter = MediaExternalExporter(externalAsset: stockPhotosMedia)
105+
return exporter
106+
case let tenorMedia as TenorMedia:
107+
let exporter = MediaExternalExporter(externalAsset: tenorMedia)
108+
return exporter
109+
default:
110+
return nil
111+
}
112+
}
113+
114+
func configureMedia(_ media: Media, withExport export: MediaExport) {
115+
media.absoluteLocalURL = export.url
116+
media.filename = export.url.lastPathComponent
117+
media.mediaType = (export.url as NSURL).assetMediaType
118+
119+
if let fileSize = export.fileSize {
120+
media.filesize = fileSize as NSNumber
121+
}
122+
123+
if let width = export.width {
124+
media.width = width as NSNumber
125+
}
126+
127+
if let height = export.height {
128+
media.height = height as NSNumber
129+
}
130+
131+
if let duration = export.duration {
132+
media.length = duration as NSNumber
133+
}
134+
135+
if let caption = export.caption {
136+
media.caption = caption
137+
}
138+
}
139+
140+
struct ExportOptions {
141+
var imageOptions: MediaImageExporter.Options
142+
var videoOptions: MediaVideoExporter.Options
143+
var urlOptions: MediaURLExporter.Options
144+
var allowableFileExtensions: Set<String>
145+
}
146+
147+
func makeExportOptions(for blog: Blog, allowableFileExtensions: Set<String>) -> ExportOptions {
148+
ExportOptions(imageOptions: exporterImageOptions,
149+
videoOptions: makeExporterVideoOptions(for: blog),
150+
urlOptions: exporterURLOptions(allowableFileExtensions: allowableFileExtensions),
151+
allowableFileExtensions: allowableFileExtensions)
152+
}
153+
154+
var exporterImageOptions: MediaImageExporter.Options {
155+
var options = MediaImageExporter.Options()
156+
options.maximumImageSize = self.exporterMaximumImageSize()
157+
options.stripsGeoLocationIfNeeded = MediaSettings().removeLocationSetting
158+
options.imageCompressionQuality = MediaSettings().imageQualityForUpload.doubleValue
159+
return options
160+
}
161+
162+
func makeExporterVideoOptions(for blog: Blog) -> MediaVideoExporter.Options {
163+
var options = MediaVideoExporter.Options()
164+
options.stripsGeoLocationIfNeeded = MediaSettings().removeLocationSetting
165+
options.exportPreset = MediaSettings().maxVideoSizeSetting.videoPreset
166+
options.durationLimit = blog.videoDurationLimit
167+
return options
168+
}
169+
170+
func exporterURLOptions(allowableFileExtensions: Set<String>) -> MediaURLExporter.Options {
171+
var options = MediaURLExporter.Options()
172+
options.allowableFileExtensions = allowableFileExtensions
173+
options.stripsGeoLocationIfNeeded = MediaSettings().removeLocationSetting
174+
return options
175+
}
176+
177+
/// Helper method to return an optional value for a valid MediaSettings max image upload size.
178+
///
179+
/// - Note: Eventually we'll rewrite MediaSettings.imageSizeForUpload to do this for us, but want to leave
180+
/// that class alone while implementing MediaExportService.
181+
///
182+
func exporterMaximumImageSize() -> CGFloat? {
183+
let maxUploadSize = MediaSettings().imageSizeForUpload
184+
if maxUploadSize < Int.max {
185+
return CGFloat(maxUploadSize)
186+
}
187+
return nil
188+
}
189+
190+
func updateMedia(_ media: Media, with remote: MediaWithEditContext) {
191+
media.mediaID = NSNumber(value: remote.id)
192+
media.remoteURL = remote.sourceUrl
193+
media.creationDate = remote.dateGmt
194+
media.postID = remote.postId.map { NSNumber(value: $0) }
195+
media.title = remote.title.raw
196+
media.caption = remote.caption.raw
197+
media.desc = remote.description.raw
198+
media.alt = remote.altText
199+
200+
if let url = URL(string: remote.sourceUrl) {
201+
media.filename = url.lastPathComponent
202+
media.setMediaType(forFilenameExtension: url.pathExtension)
203+
}
204+
205+
if case let .object(mediaDetails) = remote.mediaDetails {
206+
if case let .int(width) = mediaDetails["width"] {
207+
media.width = NSNumber(value: width)
208+
}
209+
if case let .int(height) = mediaDetails["height"] {
210+
media.height = NSNumber(value: height)
211+
}
212+
if case let .int(length) = mediaDetails["length"] {
213+
media.length = NSNumber(value: length)
214+
}
215+
if case let .string(file) = mediaDetails["file"] {
216+
media.filename = file
217+
}
218+
219+
// Extract different sizes
220+
if case let .object(sizes) = mediaDetails["sizes"] {
221+
if case let .object(medium) = sizes["medium"],
222+
case let .string(url) = medium["source_url"] {
223+
media.remoteMediumURL = url
224+
}
225+
if case let .object(large) = sizes["large"],
226+
case let .string(url) = large["source_url"] {
227+
media.remoteLargeURL = url
228+
}
229+
if case let .object(thumbnail) = sizes["thumbnail"],
230+
case let .string(url) = thumbnail["source_url"] {
231+
media.remoteThumbnailURL = url
232+
}
233+
}
234+
}
235+
236+
media.remoteStatus = .sync
237+
media.error = nil
238+
}
239+
}
240+
241+
private extension MediaCreateParams {
242+
init(from export: MediaExport) {
243+
self.init(
244+
date: nil,
245+
dateGmt: nil,
246+
slug: nil,
247+
status: nil,
248+
title: export.url.lastPathComponent, // TODO: Add a `filename` property to `MediaExport`.
249+
author: nil,
250+
commentStatus: nil,
251+
pingStatus: nil,
252+
template: nil,
253+
altText: nil,
254+
caption: export.caption,
255+
description: nil,
256+
postId: nil
257+
)
258+
}
259+
}

0 commit comments

Comments
 (0)