Skip to content

Commit

Permalink
Add Media importer for SmugMug
Browse files Browse the repository at this point in the history
  • Loading branch information
kateyeo committed Sep 26, 2023
1 parent 768ba1a commit 1a409b2
Show file tree
Hide file tree
Showing 7 changed files with 606 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

package org.datatransferproject.transfer.smugmug.photos;
package org.datatransferproject.transfer.smugmug;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
Expand All @@ -39,12 +39,14 @@
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.datatransferproject.transfer.smugmug.photos.SmugMugOauthApi;
import org.datatransferproject.transfer.smugmug.photos.model.*;
import org.datatransferproject.transfer.smugmug.photos.model.SmugMugAlbumImageResponse;
import org.datatransferproject.transfer.smugmug.photos.model.SmugMugAlbumResponse;
import org.datatransferproject.transfer.smugmug.photos.model.SmugMugAlbumsResponse;
import org.datatransferproject.transfer.smugmug.photos.model.SmugMugImageUploadResponse;
import org.datatransferproject.types.common.models.photos.PhotoModel;
import org.datatransferproject.types.common.models.videos.VideoModel;
import org.datatransferproject.types.transfer.auth.AppCredentials;
import org.datatransferproject.types.transfer.auth.TokenSecretAuthData;

Expand All @@ -60,7 +62,8 @@ public class SmugMugInterface {
private final ObjectMapper mapper;
private final SmugMugUser user;

SmugMugInterface(AppCredentials appCredentials, TokenSecretAuthData authData, ObjectMapper mapper)
public SmugMugInterface(AppCredentials appCredentials, TokenSecretAuthData authData,
ObjectMapper mapper)
throws IOException {
this.oAuthService =
new ServiceBuilder(appCredentials.getKey())
Expand All @@ -71,7 +74,7 @@ public class SmugMugInterface {
this.user = getUserInformation().getUser();
}

SmugMugAlbumImageResponse getListOfAlbumImages(String url) throws IOException {
public SmugMugAlbumImageResponse getListOfAlbumImages(String url) throws IOException {
Preconditions.checkArgument(
!Strings.isNullOrEmpty(url), "Album URI is required to retrieve album information");
SmugMugAlbumImageResponse response =
Expand All @@ -82,7 +85,7 @@ SmugMugAlbumImageResponse getListOfAlbumImages(String url) throws IOException {

/* Returns the album corresponding to the url provided. If the url is null or empty, this
* returns the top level user albums. */
SmugMugAlbumsResponse getAlbums(String url) throws IOException {
public SmugMugAlbumsResponse getAlbums(String url) throws IOException {
if (Strings.isNullOrEmpty(url)) {
url = user.getUris().get(ALBUMS_KEY).getUri();
}
Expand All @@ -91,7 +94,7 @@ SmugMugAlbumsResponse getAlbums(String url) throws IOException {
}

/* Creates an album with albumName provided. */
SmugMugAlbumResponse createAlbum(String albumName) throws IOException {
public SmugMugAlbumResponse createAlbum(String albumName) throws IOException {
// Set up album
Map<String, String> json = new HashMap<>();
json.put("NiceName", cleanName(albumName));
Expand Down Expand Up @@ -120,7 +123,7 @@ SmugMugAlbumResponse createAlbum(String albumName) throws IOException {

/* Uploads the resource at photoUrl to the albumId provided
* The albumId must exist before calling upload, else the request will fail */
SmugMugImageUploadResponse uploadImage(
public SmugMugImageUploadResponse uploadImage(
PhotoModel photoModel, String albumUri, InputStream inputStream) throws IOException {
// Set up photo
InputStreamContent content = new InputStreamContent(null, inputStream);
Expand Down Expand Up @@ -155,6 +158,43 @@ SmugMugImageUploadResponse uploadImage(
return Preconditions.checkNotNull(response, "Image upload Response is null");
}

/* Uploads the resource at videoUrl to the albumId provided
* The albumId must exist before calling upload, else the request will fail */
public SmugMugImageUploadResponse uploadVideo(
VideoModel videoModel, String albumUri, InputStream inputStream) throws IOException {
// Set up photo
InputStreamContent content = new InputStreamContent(null, inputStream);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
content.writeTo(outputStream);
byte[] contentBytes = outputStream.toByteArray();

// Headers from: https://api.smugmug.com/api/v2/doc/reference/upload.html
Map<String, String> headersMap = new HashMap<>();
headersMap.put("X-Smug-AlbumUri", albumUri);
headersMap.put("X-Smug-ResponseType", "JSON");
headersMap.put("X-Smug-Version", "v2");
headersMap.put("Content-Type", videoModel.getMimeType());

if (!Strings.isNullOrEmpty(videoModel.getName())) {
headersMap.put("X-Smug-Title", cleanHeader(videoModel.getName()));
}
if (!Strings.isNullOrEmpty(videoModel.getDescription())) {
headersMap.put("X-Smug-Caption", cleanHeader(videoModel.getDescription()));
}

// Upload video
SmugMugImageUploadResponse response =
postRequest(
"https://upload.smugmug.com/",
ImmutableMap.of(), // No content params for video upload
contentBytes,
headersMap,
new TypeReference<SmugMugImageUploadResponse>() {});

Preconditions.checkState(response.getStat().equals("ok"), "Failed to upload image");
return Preconditions.checkNotNull(response, "Image upload Response is null");
}

private SmugMugUserResponse getUserInformation() throws IOException {
return makeRequest(USER_URL, new TypeReference<SmugMugResponse<SmugMugUserResponse>>() {})
.getResponse();
Expand Down Expand Up @@ -261,7 +301,7 @@ private <T> T postRequest(
}
}

static String cleanName(String name) {
public static String cleanName(String name) {
// TODO: Handle cases where the entire album name is non-alphanumeric, e.g. all emojis
return name.chars()
.mapToObj(c -> (char) c)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/*
* Copyright 2023 The Data Transfer Project Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.datatransferproject.transfer.smugmug.media;

import static com.google.common.base.Preconditions.checkState;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import java.io.IOException;
import java.io.InputStream;
import java.util.UUID;
import org.datatransferproject.api.launcher.Monitor;
import org.datatransferproject.spi.cloud.storage.TemporaryPerJobDataStore;
import org.datatransferproject.spi.transfer.idempotentexecutor.IdempotentImportExecutor;
import org.datatransferproject.spi.transfer.provider.ImportResult;
import org.datatransferproject.spi.transfer.provider.Importer;
import org.datatransferproject.transfer.smugmug.SmugMugTransmogrificationConfig;
import org.datatransferproject.transfer.smugmug.photos.model.SmugMugAlbumResponse;
import org.datatransferproject.transfer.smugmug.photos.model.SmugMugImageUploadResponse;
import org.datatransferproject.transfer.smugmug.photos.SmugMugPhotoTempData;
import org.datatransferproject.transfer.smugmug.SmugMugInterface;
import org.datatransferproject.types.common.models.media.MediaAlbum;
import org.datatransferproject.types.common.models.media.MediaContainerResource;
import org.datatransferproject.types.common.models.photos.PhotoModel;
import org.datatransferproject.types.common.models.videos.VideoModel;
import org.datatransferproject.types.transfer.auth.AppCredentials;
import org.datatransferproject.types.transfer.auth.TokenSecretAuthData;

public class SmugMugMediaImporter
implements Importer<TokenSecretAuthData, MediaContainerResource> {

private static final String DEFAULT_ALBUM_NAME = "Untitled Album";
private final TemporaryPerJobDataStore jobStore;
private final AppCredentials appCredentials;
private final ObjectMapper mapper;
private final Monitor monitor;
private final SmugMugTransmogrificationConfig transmogrificationConfig;
private final SmugMugInterface smugMugInterface;

public SmugMugMediaImporter(
TemporaryPerJobDataStore jobStore,
AppCredentials appCredentials,
ObjectMapper mapper,
Monitor monitor) {
this(null, new SmugMugTransmogrificationConfig(), jobStore, appCredentials, mapper, monitor);
}

@VisibleForTesting
SmugMugMediaImporter(
SmugMugInterface smugMugInterface,
SmugMugTransmogrificationConfig transmogrificationConfig,
TemporaryPerJobDataStore jobStore,
AppCredentials appCredentials,
ObjectMapper mapper,
Monitor monitor) {
this.smugMugInterface = smugMugInterface;
this.transmogrificationConfig = transmogrificationConfig;
this.jobStore = jobStore;
this.appCredentials = appCredentials;
this.mapper = mapper;
this.monitor = monitor;
}

@Override
public ImportResult importItem(
UUID jobId,
IdempotentImportExecutor idempotentExecutor,
TokenSecretAuthData authData,
MediaContainerResource data)
throws Exception {

// Make the data smugmug compatible
data.transmogrify(transmogrificationConfig);

try {
SmugMugInterface smugMugInterface = getOrCreateSmugMugInterface(authData);
for (MediaAlbum album : data.getAlbums()) {
idempotentExecutor.executeAndSwallowIOExceptions(
album.getId(),
album.getName(),
() -> importSingleAlbum(jobId, album, smugMugInterface));
}
for (PhotoModel photo : data.getPhotos()) {
idempotentExecutor.executeAndSwallowIOExceptions(
photo.getIdempotentId(),
photo.getTitle(),
() -> importSinglePhoto(jobId, idempotentExecutor, photo, smugMugInterface));
}
for (VideoModel video : data.getVideos()) {
idempotentExecutor.executeAndSwallowIOExceptions(
video.getIdempotentId(),
video.getName(),
() -> importSingleVideo(jobId, idempotentExecutor, video, smugMugInterface));
}

} catch (IOException e) {
monitor.severe(() -> "Error importing", e);
return new ImportResult(e);
}
return ImportResult.OK;
}

@VisibleForTesting
String importSingleAlbum(UUID jobId, MediaAlbum inputAlbum, SmugMugInterface smugMugInterface)
throws IOException {
String albumName =
Strings.isNullOrEmpty(inputAlbum.getName())
? DEFAULT_ALBUM_NAME
: inputAlbum.getName();

SmugMugAlbumResponse albumResponse = smugMugInterface.createAlbum(albumName);
SmugMugPhotoTempData tempData =
new SmugMugPhotoTempData(
inputAlbum.getId(), albumName, inputAlbum.getDescription(), albumResponse.getUri());
jobStore.create(jobId, getTempDataId(inputAlbum.getId()), tempData);
return albumResponse.getUri();
}

@VisibleForTesting
String importSinglePhoto(
UUID jobId,
IdempotentImportExecutor idempotentExecutor,
PhotoModel inputPhoto,
SmugMugInterface smugMugInterface)
throws Exception {
InputStream inputStream;
if (inputPhoto.isInTempStore()) {
inputStream = jobStore.getStream(jobId, inputPhoto.getFetchableUrl()).getStream();
} else {
inputStream = smugMugInterface.getImageAsStream(inputPhoto.getFetchableUrl());
}

String originalAlbumId = inputPhoto.getAlbumId();
SmugMugPhotoTempData albumTempData =
getDestinationAlbumTempData(jobId, idempotentExecutor, originalAlbumId, smugMugInterface);

SmugMugImageUploadResponse response =
smugMugInterface.uploadImage(inputPhoto, albumTempData.getAlbumUri(), inputStream);
albumTempData.incrementPhotoCount();
jobStore.update(jobId, getTempDataId(albumTempData.getAlbumExportId()), albumTempData);

return response.toString();
}

@VisibleForTesting
String importSingleVideo(
UUID jobId,
IdempotentImportExecutor idempotentExecutor,
VideoModel inputVideo,
SmugMugInterface smugMugInterface)
throws Exception {
InputStream inputStream;
if (inputVideo.isInTempStore()) {
inputStream = jobStore.getStream(jobId, inputVideo.getFetchableUrl()).getStream();
} else {
inputStream = smugMugInterface.getImageAsStream(inputVideo.getFetchableUrl());
}

String originalAlbumId = inputVideo.getAlbumId();
SmugMugPhotoTempData albumTempData =
getDestinationAlbumTempData(jobId, idempotentExecutor, originalAlbumId, smugMugInterface);

SmugMugImageUploadResponse response =
smugMugInterface.uploadVideo(inputVideo, albumTempData.getAlbumUri(), inputStream);

albumTempData.incrementPhotoCount();
jobStore.update(jobId, getTempDataId(albumTempData.getAlbumExportId()), albumTempData);

return response.toString();
}

// Returns the provided interface, or a new one specific to the authData provided.
private SmugMugInterface getOrCreateSmugMugInterface(TokenSecretAuthData authData)
throws IOException {
return smugMugInterface == null
? new SmugMugInterface(appCredentials, authData, mapper)
: smugMugInterface;
}

/**
* Get the proper album upload information for the photo. Takes into account size limits of the
* albums and completed uploads.
*/
@VisibleForTesting
SmugMugPhotoTempData getDestinationAlbumTempData(
UUID jobId,
IdempotentImportExecutor idempotentExecutor,
String baseAlbumId,
SmugMugInterface smugMugInterface)
throws Exception {
SmugMugPhotoTempData baseAlbumTempData =
jobStore.findData(jobId, getTempDataId(baseAlbumId), SmugMugPhotoTempData.class);
SmugMugPhotoTempData albumTempData = baseAlbumTempData;
int depth = 0;
while (albumTempData.getPhotoCount() >= transmogrificationConfig.getAlbumMaxSize()) {
if (albumTempData.getOverflowAlbumExportId() == null) {
MediaAlbum newAlbum =
createOverflowAlbum(
baseAlbumTempData.getAlbumExportId(),
baseAlbumTempData.getAlbumName(),
baseAlbumTempData.getAlbumDescription(),
depth + 1);
// since the album is full and has no overflow, we need to create a new one
String newUri =
idempotentExecutor.executeOrThrowException(
newAlbum.getId(),
newAlbum.getName(),
() -> importSingleAlbum(jobId, newAlbum, smugMugInterface));
albumTempData.setOverflowAlbumExportId(newAlbum.getId());
jobStore.update(jobId, getTempDataId(albumTempData.getAlbumExportId()), albumTempData);
albumTempData =
jobStore.findData(
jobId,
getTempDataId(albumTempData.getOverflowAlbumExportId()),
SmugMugPhotoTempData.class);
} else {
albumTempData =
jobStore.findData(
jobId,
getTempDataId(albumTempData.getOverflowAlbumExportId()),
SmugMugPhotoTempData.class);
}
depth += 1;
}
return albumTempData;
}

private static String getTempDataId(String albumId) {
return String.format("smugmug-album-temp-data-%s", albumId);
}

/**
* Create an overflow album using the base album's id, name, and description and the overflow
* album's opy number. E.g. if baseAlbum needs a single overflow album, it will be created with
* createOverflowAlbum("baseAlbumId", "baseAlbumName", "baseAlbumDescription", 1) and result in an
* album PhotoAlbum("baseAlbumId-overflow-1", "baseAlbumName (1)", "baseAlbumDescription")
*/
private static MediaAlbum createOverflowAlbum(
String baseAlbumId, String baseAlbumName, String baseAlbumDescription, int copyNumber)
throws Exception {
checkState(copyNumber > 0, "copyNumber should be > 0");
return new MediaAlbum(
String.format("%s-overflow-%d", baseAlbumId, copyNumber),
String.format("%s (%d)", baseAlbumName, copyNumber),
baseAlbumDescription);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.datatransferproject.spi.transfer.provider.ExportResult.ResultType;
import org.datatransferproject.spi.transfer.provider.Exporter;
import org.datatransferproject.spi.transfer.types.ContinuationData;
import org.datatransferproject.transfer.smugmug.SmugMugInterface;
import org.datatransferproject.transfer.smugmug.photos.model.SmugMugAlbum;
import org.datatransferproject.transfer.smugmug.photos.model.SmugMugAlbumImageResponse;
import org.datatransferproject.transfer.smugmug.photos.model.SmugMugAlbumsResponse;
Expand Down
Loading

0 comments on commit 1a409b2

Please sign in to comment.