Skip to content

Commit

Permalink
Moving to new Google Photos API, exporter (dtinit#439)
Browse files Browse the repository at this point in the history
  • Loading branch information
olsona committed Jun 20, 2018
1 parent 2d87979 commit 5d7b55e
Show file tree
Hide file tree
Showing 17 changed files with 811 additions and 177 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ public class GoogleAuthDataGenerator implements AuthDataGenerator {
ImmutableMap.<String, List<String>>builder()
.put("calendar", ImmutableList.of(CalendarScopes.CALENDAR_READONLY))
.put("mail", ImmutableList.of(GmailScopes.GMAIL_READONLY))
// picasaweb does not have a READONLY scope
.put("photos", ImmutableList.of("https://picasaweb.google.com/data/"))
.put("photos", ImmutableList.of("https://www.googleapis.com/auth/photoslibrary.readonly"))
.put("tasks", ImmutableList.of(TasksScopes.TASKS_READONLY))
.put("contacts", ImmutableList.of(PeopleServiceScopes.CONTACTS_READONLY))
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@
import com.google.api.client.auth.oauth2.Credential;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.gdata.client.photos.PicasawebService;
import com.google.gdata.data.MediaContent;
import com.google.gdata.data.photos.AlbumFeed;
import com.google.gdata.data.photos.GphotoEntry;
import com.google.gdata.data.photos.UserFeed;
import com.google.gdata.util.ServiceException;
import com.google.common.base.Strings;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.dataportabilityproject.datatransfer.google.common.GoogleCredentialFactory;
import org.dataportabilityproject.datatransfer.google.common.GoogleStaticObjects;
import org.dataportabilityproject.datatransfer.google.photos.model.AlbumListResponse;
import org.dataportabilityproject.datatransfer.google.photos.model.GoogleAlbum;
import org.dataportabilityproject.datatransfer.google.photos.model.GoogleMediaItem;
import org.dataportabilityproject.datatransfer.google.photos.model.MediaItemSearchResponse;
import org.dataportabilityproject.spi.transfer.provider.ExportResult;
import org.dataportabilityproject.spi.transfer.provider.ExportResult.ResultType;
import org.dataportabilityproject.spi.transfer.provider.Exporter;
Expand All @@ -39,43 +42,23 @@
import org.dataportabilityproject.types.transfer.models.photos.PhotoModel;
import org.dataportabilityproject.types.transfer.models.photos.PhotosContainerResource;

import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

public class GooglePhotosExporter
implements Exporter<TokensAndUrlAuthData, PhotosContainerResource> {

static final String ALBUM_TOKEN_PREFIX = "album:";
static final String PHOTO_TOKEN_PREFIX = "photo:";

// TODO(olsona): figure out optimal value here
static final int MAX_RESULTS_DEFAULT = 100;

static final String URL_ALBUM_FEED_FORMAT =
"https://picasaweb.google.com/data/feed/api/user/default?kind=album&start-index=%d&max-results=%d";
// imgmax=d gets the original image as per
// https://developers.google.com/picasa-web/docs/3.0/reference
static final String URL_PHOTO_FEED_FORMAT =
"https://picasaweb.google.com/data/feed/api/user/default/albumid/%s?imgmax=d&start-index=%s&max-results=%d";
static final String PHOTO_TOKEN_PREFIX = "media:";

private final GoogleCredentialFactory credentialFactory;
private final int maxResults;
private volatile PicasawebService photosService;
private volatile GooglePhotosInterface photosInterface;

public GooglePhotosExporter(GoogleCredentialFactory credentialFactory) {
this(credentialFactory, null, MAX_RESULTS_DEFAULT);
this.credentialFactory = credentialFactory;
}

@VisibleForTesting
GooglePhotosExporter(GoogleCredentialFactory credentialFactory, PicasawebService photosService,
int maxResults) {
GooglePhotosExporter(GoogleCredentialFactory credentialFactory, GooglePhotosInterface photosInterface) {
this.credentialFactory = credentialFactory;
this.photosService = photosService;
this.maxResults = maxResults;
this.photosInterface = photosInterface;
}

@Override
Expand All @@ -102,44 +85,41 @@ public ExportResult<PhotosContainerResource> export(

private ExportResult<PhotosContainerResource> exportAlbums(
TokensAndUrlAuthData authData, Optional<PaginationData> paginationData) {
int startItem = 1;
Optional<String> paginationToken = Optional.empty();
if (paginationData.isPresent()) {
String token = ((StringPaginationToken) paginationData.get()).getToken();
Preconditions.checkArgument(
token.startsWith(ALBUM_TOKEN_PREFIX), "Invalid pagination token " + token);
startItem = Integer.parseInt(token.substring(ALBUM_TOKEN_PREFIX.length()));
paginationToken = Optional.of(token.substring(ALBUM_TOKEN_PREFIX.length()));
}

URL albumUrl;
UserFeed albumFeed;
AlbumListResponse albumListResponse;

try {
albumUrl = new URL(String.format(URL_ALBUM_FEED_FORMAT, startItem, maxResults));
albumFeed = getOrCreatePhotosService(authData).getFeed(albumUrl, UserFeed.class);
} catch (ServiceException | IOException e) {
albumListResponse = getOrCreatePhotosInterface(authData).listAlbums(paginationToken);
} catch (IOException e) {
return new ExportResult<>(ResultType.ERROR, e.getMessage());
}

PaginationData nextPageData = null;
List<GphotoEntry> entries = albumFeed.getEntries();
if (entries.size() == maxResults) {
int nextPageStart = startItem + maxResults;
nextPageData = new StringPaginationToken(ALBUM_TOKEN_PREFIX + nextPageStart);
if (!Strings.isNullOrEmpty(albumListResponse.getNextPageToken())) {
nextPageData = new StringPaginationToken(
ALBUM_TOKEN_PREFIX + albumListResponse.getNextPageToken());
}

ContinuationData continuationData = new ContinuationData(nextPageData);
List<PhotoAlbum> albums = new ArrayList<>(entries.size());
List<PhotoAlbum> albums = new ArrayList<>();

for (GphotoEntry googleAlbum : entries) {
for (GoogleAlbum googleAlbum : albumListResponse.getAlbums()) {
// Add album info to list so album can be recreated later
albums.add(
new PhotoAlbum(
googleAlbum.getGphotoId(),
googleAlbum.getTitle().getPlainText(),
googleAlbum.getDescription().getPlainText()));
googleAlbum.getId(),
googleAlbum.getTitle(),
null));

// Add album id to continuation data
continuationData.addContainerResource(new IdOnlyContainerResource(googleAlbum.getGphotoId()));
continuationData.addContainerResource(new IdOnlyContainerResource(googleAlbum.getId()));
}

ResultType resultType = ResultType.CONTINUE;
Expand All @@ -152,45 +132,43 @@ private ExportResult<PhotosContainerResource> exportAlbums(

private ExportResult<PhotosContainerResource> exportPhotos(
TokensAndUrlAuthData authData, String albumId, Optional<PaginationData> paginationData) {

int startItem = 1;
Optional<String> paginationToken = Optional.empty();
if (paginationData.isPresent()) {
String token = ((StringPaginationToken) paginationData.get()).getToken();
Preconditions.checkArgument(
token.startsWith(PHOTO_TOKEN_PREFIX), "Invalid pagination token " + token);
startItem = Integer.parseInt(token.substring(PHOTO_TOKEN_PREFIX.length()));
paginationToken = Optional.of(token.substring(PHOTO_TOKEN_PREFIX.length()));
}

URL photosUrl;
AlbumFeed photoFeed;
MediaItemSearchResponse mediaItemSearchResponse;

try {
photosUrl = new URL(String.format(URL_PHOTO_FEED_FORMAT, albumId, startItem, maxResults));
photoFeed = getOrCreatePhotosService(authData).getFeed(photosUrl, AlbumFeed.class);

} catch (ServiceException | IOException e) {
mediaItemSearchResponse = getOrCreatePhotosInterface(authData)
.listAlbumContents(albumId, paginationToken);
} catch (IOException e) {
return new ExportResult<>(ResultType.ERROR, e.getMessage());
}

PaginationData nextPageData = null;
List<GphotoEntry> entries = photoFeed.getEntries();
if (entries.size() == maxResults) {
int nextPageStart = startItem + maxResults;
nextPageData = new StringPaginationToken(PHOTO_TOKEN_PREFIX + nextPageStart);
if (!Strings.isNullOrEmpty(mediaItemSearchResponse.getNextPageToken())) {
nextPageData = new StringPaginationToken(
PHOTO_TOKEN_PREFIX + mediaItemSearchResponse.getNextPageToken());
}
ContinuationData continuationData = new ContinuationData(nextPageData);

List<PhotoModel> photos = new ArrayList<>(entries.size());
for (GphotoEntry photo : entries) {
MediaContent mediaContent = (MediaContent) photo.getContent();
photos.add(
new PhotoModel(
photo.getTitle().getPlainText(),
mediaContent.getUri(),
photo.getDescription().getPlainText(),
mediaContent.getMimeType().getMediaType(),
null,
albumId));
List<PhotoModel> photos = new ArrayList<>();
for (GoogleMediaItem mediaItem : mediaItemSearchResponse.getMediaItems()) {
if (mediaItem.getMediaMetadata().getPhoto() != null) {
// TODO: address videos later on
photos.add(
new PhotoModel(
"", // TODO: no title?
mediaItem.getProductUrl(), // TODO: check this
mediaItem.getDescription(),
mediaItem.getMimeType(),
mediaItem.getId(),
albumId));
}
}

PhotosContainerResource containerResource = new PhotosContainerResource(null, photos);
Expand All @@ -202,14 +180,13 @@ private ExportResult<PhotosContainerResource> exportPhotos(
return new ExportResult<>(resultType, containerResource, continuationData);
}

private PicasawebService getOrCreatePhotosService(TokensAndUrlAuthData authData) {
return photosService == null ? makePhotosService(authData) : photosService;
private synchronized GooglePhotosInterface getOrCreatePhotosInterface(TokensAndUrlAuthData authData) {
return photosInterface == null ? makePhotosInterface(authData) : photosInterface;
}

private synchronized PicasawebService makePhotosService(TokensAndUrlAuthData authData) {
private synchronized GooglePhotosInterface makePhotosInterface(TokensAndUrlAuthData authData) {
Credential credential = credentialFactory.createCredential(authData);
PicasawebService service = new PicasawebService(GoogleStaticObjects.APP_NAME);
service.setOAuth2Credentials(credential);
return service;
GooglePhotosInterface photosInterface = new GooglePhotosInterface(credential);
return photosInterface;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright 2018 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.dataportabilityproject.datatransfer.google.photos;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpContent;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.http.json.JsonHttpContent;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.ArrayMap;
import com.google.common.base.Charsets;
import com.google.common.io.CharStreams;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.dataportabilityproject.datatransfer.google.photos.model.AlbumListResponse;
import org.dataportabilityproject.datatransfer.google.photos.model.MediaItemSearchResponse;
import org.dataportabilityproject.types.transfer.auth.TokensAndUrlAuthData;

public class GooglePhotosInterface {

private static final String BASE_URL = "https://photoslibrary.googleapis.com/v1/";
private static final int ALBUM_PAGE_SIZE = 20; // TODO
private static final int MEDIA_PAGE_SIZE = 100; // TODO

private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpTransport httpTransport = new NetHttpTransport();
private final Credential credential;

public GooglePhotosInterface(Credential credential) {
this.credential = credential;
}

public AlbumListResponse listAlbums(Optional<String> pageToken) throws IOException {
Map<String, String> params = new LinkedHashMap<>();
params.put("pageSize", String.valueOf(ALBUM_PAGE_SIZE));
if (pageToken.isPresent()) {
params.put("pageToken", pageToken.get());
}
return makeGetRequest(BASE_URL + "albums", Optional.of(params),
AlbumListResponse.class);
}

public MediaItemSearchResponse listAlbumContents(String albumId, Optional<String> pageToken)
throws IOException {
Map<String, String> params = new LinkedHashMap<>();
params.put("pageSize", String.valueOf(MEDIA_PAGE_SIZE));
params.put("albumId", albumId);
if (pageToken.isPresent()) {
params.put("pageToken", pageToken.get());
}
HttpContent content = new JsonHttpContent(new JacksonFactory(), params);
return makePostRequest(BASE_URL + "mediaItems:search", Optional.empty(), content,
MediaItemSearchResponse.class);
}

private <T> T makeGetRequest(String url, Optional<Map<String, String>> parameters, Class<T> clazz)
throws IOException {
HttpRequestFactory requestFactory = httpTransport.createRequestFactory();
HttpRequest getRequest = requestFactory
.buildGetRequest(new GenericUrl(url + "?" + generateParamsString(parameters)));
HttpResponse response = getRequest.execute();
int statusCode = response.getStatusCode();
if (statusCode != 200) {
throw new IOException(
"Bad status code: " + statusCode + " error: " + response.getStatusMessage());
}
String result = CharStreams
.toString(new InputStreamReader(response.getContent(), Charsets.UTF_8));
return objectMapper.readValue(result, clazz);
}

private <T> T makePostRequest(String url, Optional<Map<String, String>> parameters,
HttpContent httpContent, Class<T> clazz)
throws IOException {
HttpRequestFactory requestFactory = httpTransport.createRequestFactory();
HttpRequest getRequest = requestFactory
.buildPostRequest(new GenericUrl(url + "?" + generateParamsString(parameters)),
httpContent);
HttpResponse response = getRequest.execute();
int statusCode = response.getStatusCode();
if (statusCode != 200) {
throw new IOException(
"Bad status code: " + statusCode + " error: " + response.getStatusMessage());
}
String result = CharStreams
.toString(new InputStreamReader(response.getContent(), Charsets.UTF_8));
return objectMapper.readValue(result, clazz);
}

private String generateParamsString(Optional<Map<String, String>> params) {
Map<String, String> updatedParams = new ArrayMap<>();
if (params.isPresent()) {
updatedParams.putAll(params.get());
}
if (!updatedParams.containsKey("access_token")) {
updatedParams.put("access_token", credential.getAccessToken());
}

List<String> orderedKeys = updatedParams.keySet().stream().collect(Collectors.toList());
Collections.sort(orderedKeys);

List<String> paramStrings = new ArrayList<>();
for (String key : orderedKeys) {
String k = key.trim();
String v = updatedParams.get(key).trim();

paramStrings.add(k + "=" + v);
}

return String.join("&", paramStrings);
}
}
Loading

0 comments on commit 5d7b55e

Please sign in to comment.