/*
* Copyright (c) 2010 Google Inc.
*
* 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
*
* http://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 com.google.ytd.picasa;
import com.google.appengine.api.blobstore.BlobstoreService;
import com.google.appengine.api.blobstore.BlobstoreServiceFactory;
import com.google.appengine.api.utils.SystemProperty;
import com.google.gdata.client.photos.PicasawebService;
import com.google.gdata.data.Link;
import com.google.gdata.data.ParseSource;
import com.google.gdata.data.PlainTextConstruct;
import com.google.gdata.data.photos.AlbumEntry;
import com.google.gdata.data.photos.AlbumFeed;
import com.google.gdata.data.photos.GphotoAccess;
import com.google.gdata.data.photos.PhotoEntry;
import com.google.gdata.data.photos.UserFeed;
import com.google.gdata.util.ParseUtil;
import com.google.gdata.util.ServiceException;
import com.google.inject.Inject;
import com.google.ytd.dao.AdminConfigDao;
import com.google.ytd.dao.AssignmentDao;
import com.google.ytd.dao.DataChunkDao;
import com.google.ytd.util.Util;
import org.apache.commons.lang.StringEscapeUtils;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Class to handle interfacing with the Google Data Java Client Library's Picasa
* support.
*/
public class PicasaApiHelper {
private static final Logger LOG = Logger.getLogger(PicasaApiHelper.class.getName());
// CONSTANTS
private static final String USER_FEED_URL =
"http://picasaweb.google.com/data/feed/api/user/default";
private static final String ALBUM_FEED_URL =
"http://picasaweb.google.com/data/feed/api/user/default?kind=album&max-results=1000";
private static final String RESUMABLE_UPLOADS_URL_FORMAT =
"http://picasaweb.google.com/data/upload/resumable/photos/create-session/feed/api/user/default/albumid/%s";
private static final String UPLOAD_ENTRY_XML_FORMAT =
"<?xml version='1.0' encoding='UTF-8'?>\n"
+ "<entry xmlns='http://www.w3.org/2005/Atom' "
+ "xmlns:georss='http://www.georss.org/georss' xmlns:gml='http://www.opengis.net/gml'>"
+ "\n <title>%s</title>"
+ "\n <summary>%s</summary>"
+ "\n <category scheme='http://schemas.google.com/g/2005#kind' "
+ "term='http://schemas.google.com/photos/2007#photo'/>"
+ "\n%s</entry>";
private static final String GEO_RSS_XML_FORMAT = "<georss:where><gml:Point><gml:pos>%f %f"
+ "</gml:pos></gml:Point></georss:where>";
// The connect + read timeout needs to be <= 10 seconds, due to App Engine limitations.
private static final int CONNECT_TIMEOUT = 1000 * 2; // In milliseconds
private static final int READ_TIMEOUT = 1000 * 8; // In milliseconds
// The size of each resumable upload chunk we send. Due to App Engine limitations, this needs to
// be less than 1MB.
private static final int CHUNK_SIZE = 950 * 1024; // 950KB
private PicasawebService service = null;
private Util util = null;
private AdminConfigDao adminConfigDao = null;
private DataChunkDao dataChunkDao = null;
@Inject
public PicasaApiHelper(AdminConfigDao adminConfigDao, AssignmentDao assignmentDao,
DataChunkDao dataChunkDao) {
this.service = new PicasawebService(Util.CLIENT_ID_PREFIX + SystemProperty.applicationId.get());
this.util = Util.get();
this.adminConfigDao = adminConfigDao;
this.dataChunkDao = dataChunkDao;
setAuthSubTokenFromConfig();
service.setConnectTimeout(CONNECT_TIMEOUT);
service.setReadTimeout(READ_TIMEOUT);
}
public boolean isAuthenticated() {
return service.getAuthTokenFactory().getAuthToken() != null;
}
public void setAuthSubTokenFromConfig() {
String authSubToken = adminConfigDao.getAdminConfig().getPicasaAuthSubToken();
if (!util.isNullOrEmpty(authSubToken)) {
service.setAuthSubToken(authSubToken);
}
}
public void setAuthSubToken(String token) {
service.setAuthSubToken(token);
}
public String getCurrentUsername() throws IOException, ServiceException {
try {
UserFeed userFeed = service.getFeed(new URL(USER_FEED_URL), UserFeed.class);
return userFeed.getUsername();
} catch (MalformedURLException e) {
LOG.log(Level.WARNING, "", e);
}
return null;
}
public List<AlbumEntry> getAllAlbums() {
ArrayList<AlbumEntry> albums = new ArrayList<AlbumEntry>();
try {
URL feedUrl = new URL(ALBUM_FEED_URL);
while (feedUrl != null) {
UserFeed albumFeed = service.getFeed(feedUrl, UserFeed.class);
albums.addAll(albumFeed.getAlbumEntries());
Link nextLink = albumFeed.getNextLink();
if (nextLink == null) {
feedUrl = null;
} else {
feedUrl = new URL(nextLink.getHref());
}
}
return albums;
} catch (MalformedURLException e) {
LOG.log(Level.WARNING, "", e);
} catch (IOException e) {
LOG.log(Level.WARNING, "", e);
} catch (ServiceException e) {
LOG.log(Level.WARNING, "", e);
}
return null;
}
public String createAlbum(String title, String description, boolean privateAlbum) {
LOG.info(String.format("Attempting to create %s Picasa album...",
privateAlbum ? "private" : "public"));
AlbumEntry album = new AlbumEntry();
if (privateAlbum) {
album.setAccess(GphotoAccess.Value.PRIVATE);
} else {
album.setAccess(GphotoAccess.Value.PUBLIC);
}
album.setTitle(new PlainTextConstruct(title));
album.setDescription(new PlainTextConstruct(description));
try {
AlbumEntry albumEntry = service.insert(new URL(USER_FEED_URL), album);
String albumUrl = albumEntry.getFeedLink().getHref();
LOG.info(String.format("Created %s Picasa album: %s",
privateAlbum ? "private" : "public", albumUrl));
return albumUrl;
} catch (MalformedURLException e) {
LOG.log(Level.WARNING, "", e);
} catch (IOException e) {
LOG.log(Level.WARNING, "", e);
} catch (ServiceException e) {
LOG.log(Level.WARNING, "", e);
}
return null;
}
public String moveToNewAlbum(String photoUrl, String newAlbumUrl) {
LOG.info(String.format("Preparing to move '%s' to album '%s'...", photoUrl, newAlbumUrl));
// We only need to get the feed's id from the feed metadata here, so there's no need to
// retrieve more than one entry.
String urlWithParam;
if (newAlbumUrl.indexOf("?") != -1) {
urlWithParam = newAlbumUrl + "&max-results=1";
} else {
urlWithParam = newAlbumUrl + "?max-results=1";
}
AlbumFeed albumFeed = getAlbumFeedFromUrl(urlWithParam);
if (albumFeed == null) {
throw new IllegalArgumentException(String.format("Could not retrieve album from URL '%s'.",
urlWithParam));
}
String newAlbumId = albumFeed.getGphotoId();
com.google.gdata.data.photos.PhotoEntry photoEntry = getPhotoEntryFromUrl(photoUrl);
if (photoEntry == null) {
throw new IllegalArgumentException(String.format("Could not get photo from URL '%s'.",
photoUrl));
}
photoEntry.setAlbumId(newAlbumId);
try {
photoEntry = photoEntry.update();
LOG.info("Move was successful.");
return photoEntry.getEditLink().getHref();
} catch (IOException e) {
LOG.log(Level.WARNING, "", e);
} catch (ServiceException e) {
LOG.log(Level.WARNING, "", e);
}
return null;
}
private com.google.gdata.data.photos.PhotoEntry getPhotoEntryFromUrl(String photoUrl) {
try {
return service.getEntry(new URL(photoUrl), com.google.gdata.data.photos.PhotoEntry.class);
} catch (MalformedURLException e) {
LOG.log(Level.WARNING, "", e);
} catch (IOException e) {
LOG.log(Level.WARNING, "", e);
} catch (ServiceException e) {
LOG.log(Level.WARNING, "", e);
}
return null;
}
private AlbumFeed getAlbumFeedFromUrl(String albumUrl) {
try {
return service.getFeed(new URL(albumUrl), AlbumFeed.class);
} catch (MalformedURLException e) {
LOG.log(Level.WARNING, "", e);
} catch (IOException e) {
LOG.log(Level.WARNING, "", e);
} catch (ServiceException e) {
LOG.log(Level.WARNING, "", e);
}
return null;
}
public String getResumableUploadUrl(com.google.ytd.model.PhotoEntry photoEntry, String title,
String description, String albumId, Double latitude, Double longitude) throws IllegalArgumentException {
LOG.info(String.format("Resumable upload request.\nTitle: %s\nDescription: %s\nAlbum: %s",
title, description, albumId));
// Picasa API resumable uploads are not currently documented publicly, but they're essentially
// the same as what YouTube API offers:
// http://code.google.com/apis/youtube/2.0/developers_guide_protocol_resumable_uploads.html
// The Java client library does offer support for resumable uploads, but its use of threads
// and some other assumptions makes it unsuitable for our purposes.
try {
URL url = new URL(String.format(RESUMABLE_UPLOADS_URL_FORMAT, albumId));
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoOutput(true);
connection.setConnectTimeout(CONNECT_TIMEOUT);
connection.setReadTimeout(READ_TIMEOUT);
connection.setRequestMethod("POST");
// Set all the GData request headers. These strings should probably be moved to CONSTANTS.
connection.setRequestProperty("Content-Type", "application/atom+xml;charset=UTF-8");
connection.setRequestProperty("Authorization", String.format("AuthSub token=\"%s\"", adminConfigDao.getAdminConfig().getPicasaAuthSubToken()));
connection.setRequestProperty("GData-Version", "2.0");
connection.setRequestProperty("Slug", photoEntry.getOriginalFileName());
connection.setRequestProperty("X-Upload-Content-Type", photoEntry.getFormat());
connection.setRequestProperty("X-Upload-Content-Length",
String.valueOf(photoEntry.getOriginalFileSize()));
// If we're given lat/long then create the element to geotag the picture; otherwise, pass in
// and empty string for no geotag.
String geoRss = "";
if (latitude != null && longitude != null) {
geoRss = String.format(GEO_RSS_XML_FORMAT, latitude, longitude);
LOG.info("Geo RSS XML: " + geoRss);
}
String atomXml = String.format(UPLOAD_ENTRY_XML_FORMAT, StringEscapeUtils.escapeXml(title),
StringEscapeUtils.escapeXml(description), geoRss);
OutputStreamWriter writer = new OutputStreamWriter(connection.getOutputStream());
writer.write(atomXml);
writer.close();
if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
String uploadUrl = connection.getHeaderField("Location");
if (util.isNullOrEmpty(uploadUrl)) {
throw new IllegalArgumentException("No Location header found in HTTP response.");
} else {
LOG.info("Resumable upload URL is " + uploadUrl);
return uploadUrl;
}
} else {
LOG.warning(String.format("HTTP POST to %s returned status %d (%s).", url.toString(),
connection.getResponseCode(), connection.getResponseMessage()));
}
} catch (MalformedURLException e) {
LOG.log(Level.WARNING, "", e);
throw new IllegalArgumentException(e);
} catch (IOException e) {
LOG.log(Level.WARNING, "", e);
}
return null;
}
public PhotoEntry doResumableUpload(com.google.ytd.model.PhotoEntry photoEntry)
throws IllegalArgumentException {
if (util.isNullOrEmpty(photoEntry.getResumableUploadUrl())) {
throw new IllegalArgumentException(String.format("No resumable upload URL found for "
+ "PhotoEntry id '%s'.", photoEntry.getId()));
}
try {
URL url = new URL(photoEntry.getResumableUploadUrl());
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(CONNECT_TIMEOUT);
connection.setReadTimeout(READ_TIMEOUT);
connection.setRequestMethod("PUT");
connection.setRequestProperty("Content-Range", "bytes */*");
// Response code 308 is specific to this use case and doesn't appear to have a
// HttpURLConnection constant.
if (connection.getResponseCode() == 308) {
long previousByte = 0;
String rangeHeader = connection.getHeaderField("Range");
if (!util.isNullOrEmpty(rangeHeader)) {
LOG.info("Range header in 308 response is " + rangeHeader);
String[] rangeHeaderSplits = rangeHeader.split("-", 2);
if (rangeHeaderSplits.length == 2) {
previousByte = Long.valueOf(rangeHeaderSplits[1]).longValue() + 1;
}
}
connection = (HttpURLConnection) url.openConnection();
connection.setInstanceFollowRedirects(false);
connection.setDoOutput(true);
connection.setConnectTimeout(CONNECT_TIMEOUT);
connection.setReadTimeout(READ_TIMEOUT);
connection.setRequestMethod("PUT");
byte[] bytes;
String contentRangeHeader;
if (photoEntry.getBlobKey() != null) {
long lastByte = previousByte + CHUNK_SIZE;
if (lastByte > (photoEntry.getOriginalFileSize() - 1)) {
lastByte = photoEntry.getOriginalFileSize() - 1;
}
contentRangeHeader = String.format("bytes %d-%d/%d", previousByte, lastByte,
photoEntry.getOriginalFileSize());
BlobstoreService blobstoreService = BlobstoreServiceFactory.getBlobstoreService();
bytes = blobstoreService.fetchData(photoEntry.getBlobKey(), previousByte, lastByte);
} else {
bytes = dataChunkDao.getBytes(photoEntry.getId(), previousByte);
if (bytes == null) {
throw new IllegalArgumentException(String.format("PhotoEntry with id '%s' does not "
+ "have a valid blob key. Additionally, there is no DataChunk entry for the "
+ "initial byte '%d'.", photoEntry.getId(), previousByte));
}
contentRangeHeader = String.format("bytes %d-%d/%d", previousByte,
previousByte + bytes.length - 1, photoEntry.getOriginalFileSize());
}
connection.setRequestProperty("Content-Length", String.valueOf(bytes.length));
LOG.info("Using the following for Content-Range header: " + contentRangeHeader);
connection.setRequestProperty("Content-Range", contentRangeHeader);
OutputStream outputStream = connection.getOutputStream();
outputStream.write(bytes);
outputStream.close();
if (connection.getResponseCode() == HttpURLConnection.HTTP_CREATED) {
LOG.info("Resumable upload is complete and successful.");
return (PhotoEntry) ParseUtil.readEntry(new ParseSource(connection.getInputStream()));
}
} else if (connection.getResponseCode() == HttpURLConnection.HTTP_CREATED) {
// It's possible that the Picasa upload associated with the specific resumable upload URL
// had previously completed successfully. In that case, the response to the initial */* PUT
// will be a 201 Created with the new PhotoEntry. This is probably an edge case.
LOG.info("Resumable upload is complete and successful.");
return (PhotoEntry) ParseUtil.readEntry(new ParseSource(connection.getInputStream()));
} else {
// The IllegalArgumentException should be treated by the calling code as
// something that is not recoverable, which is to say the resumable upload attempt
// should be stopped.
throw new IllegalArgumentException(String.format("HTTP POST to %s returned status %d (%s).",
url.toString(), connection.getResponseCode(), connection.getResponseMessage()));
}
} catch (MalformedURLException e) {
LOG.log(Level.WARNING, "", e);
throw new IllegalArgumentException(e);
} catch (IOException e) {
LOG.log(Level.WARNING, "", e);
} catch (ServiceException e) {
LOG.log(Level.WARNING, "", e);
}
return null;
}
}