Commit 7db10ddf authored by Jens Georg's avatar Jens Georg

Replace Picasaweb with Google Photos publishing

Picasaweb API will cease to work end of March 2019

Fixes #97
parent 4b9d9ecc
Pipeline #53236 passed with stages
in 9 minutes and 17 seconds
......@@ -412,7 +412,7 @@
<child name="facebook" schema="org.yorba.shotwell.sharing.facebook" />
<child name="flickr" schema="org.yorba.shotwell.sharing.flickr" />
<child name="gallery3" schema="org.yorba.shotwell.sharing.publishing-gallery3" />
<child name="picasa" schema="org.yorba.shotwell.sharing.picasa" />
<child name="org-gnome-shotwell-publishing-google-photos" schema="org.yorba.shotwell.sharing.org-gnome-shotwell-publishing-google-photos" />
<child name="youtube" schema="org.yorba.shotwell.sharing.youtube" />
</schema>
......@@ -486,17 +486,17 @@
</key>
</schema>
<schema id="org.yorba.shotwell.sharing.picasa" path="/org/yorba/shotwell/sharing/picasa/">
<schema id="org.yorba.shotwell.sharing.org-gnome-shotwell-publishing-google-photos" path="/org/yorba/shotwell/sharing/org-gnome-shotwell-publishing-google-photos/">
<key name="refresh-token" type="s">
<default>""</default>
<summary>refresh token</summary>
<description>The OAuth token used to refresh the Picasa Web Albums session for the currently logged in user, if any.</description>
<description>The OAuth token used to refresh the Google Photos session for the currently logged in user, if any.</description>
</key>
<key name="default-size" type="i">
<default>2</default>
<summary>default size</summary>
<description>A numeric code representing the default size for photos uploaded to Picasa Web Albums</description>
<description>A numeric code representing the default size for photos uploaded to Google Photos Albums</description>
</key>
<key name="last-album" type="s">
......@@ -508,7 +508,7 @@
<key name="strip-metadata" type="b">
<default>false</default>
<summary>remove sensitive info from uploads</summary>
<description>Whether images being uploaded to Picasa should have their metadata removed first</description>
<description>Whether images being uploaded to Google Photos should have their metadata removed first</description>
</key>
</schema>
......@@ -721,10 +721,10 @@
<description>True if the Flickr publishing plugin is enabled, false otherwise</description>
</key>
<key name="publishing-picasa" type="b">
<key name="org-gnome-shotwell-publishing-google-photos" type="b">
<default>true</default>
<summary>enable picasa publishing plugin</summary>
<description>True if the Picasa Web Albums publishing plugin is enabled, false otherwise</description>
<summary>enable Google Photos publishing plugin</summary>
<description>True if the Google Photos publishing plugin is enabled, false otherwise</description>
</key>
<key name="publishing-youtube" type="b">
......
......@@ -17,7 +17,7 @@
</p>
<p>
When ready, Shotwell can upload your photos to various web sites, such as Facebook, Flickr,
Picasa (Google Plus), and more.
Google Photos, and more.
</p>
<p>
Shotwell supports JPEG, PNG, TIFF, and a variety of RAW file formats.
......
......@@ -4,7 +4,7 @@
<info>
<link type="guide" xref="index#share"/>
<desc>Publish photos to Facebook, Flickr, Picasa Web Albums, or other sites.</desc>
<desc>Publish photos to Flickr, Google Photos, or other sites.</desc>
<link type="next" xref="share-send" />
</info>
......@@ -17,7 +17,7 @@
<list>
<item><p><link href="https://facebook.com">Facebook</link></p></item>
<item><p><link href="https://flickr.com">Flickr</link></p></item>
<item><p><link href="https://picasaweb.google.com">Picasa Web Albums</link> and <link href="http://plus.google.com">Google+</link></p></item>
<item><p><link href="https://photos.google.com">Google Photos</link></p></item>
<item><p><link href="https://youtube.com">YouTube</link> (videos only)</p></item>
<item><p><link href="https://tumblr.com">Tumblr</link> (videos only)</p></item>
<item><p>Any site running the <link href="http://piwigo.org">Piwigo</link> photo gallery software (photos only)</p></item>
......@@ -33,7 +33,5 @@
</p>
<note style="advanced"><p>You will be only able to publish images with at most the permission you granted to the Shotwell Facebook application</p></note>
<p>Similarly, publishing to Flickr requires you to log in and permit Shotwell Connect to access your account.</p>
<p>If you have a Google account, but have not yet used Picasa Web Albums, you will need to log in to Picasa using a browser once before you can publish to this service.</p>
<p>Similarly, publishing to Flickr, YouTube or Google Photos requires you to log in and permit Shotwell Connect to access your account.</p>
</page>
option('unity-support', type: 'boolean', value : 'false', description: 'Enable Ubuntu Unity support')
option('publishers', type: 'string', value : 'flickr,picasa,piwigo,youtube,gallery3,tumblr', description: 'The list of publishing plugins to build')
option('publishers', type: 'string', value : 'flickr,googlephotos,piwigo,youtube,gallery3,tumblr', description: 'The list of publishing plugins to build')
option('extra-plugins', type : 'boolean', value : 'true', description: 'Enable building and installation of extra publishing plugins')
option('trace', type: 'string', value : '', description: 'Enable various trace options (available: dtors, import, md5, metadata-writer, monitoring, pixbuf-cache, reflow, reflow-items)')
option('measure', type: 'string', value : '', description : 'Enable various timing measurements(available : enhance, import, pipeline, view-filtering, thumbnail-cache)')
......
......@@ -14,9 +14,9 @@ namespace Publishing.Authenticator {
var list = new Gee.ArrayList<string>();
list.add("flickr");
list.add("facebook");
list.add("picasa");
list.add("youtube");
list.add("tumblr");
list.add("google-photos");
return list;
}
......@@ -28,14 +28,13 @@ namespace Publishing.Authenticator {
return new Shotwell.Flickr.Flickr(host);
case "facebook":
return new Shotwell.Facebook.Facebook(host);
case "picasa":
return new Shotwell.Google.Google("https://picasaweb.google.com/data/", _("You are not currently logged into Picasa Web Albums.\n\nClick Log in to log into Picasa Web Albums in your Web browser. You will have to authorize Shotwell Connect to link to your Picasa Web Albums account."), host);
case "youtube":
return new Shotwell.Google.Google("https://gdata.youtube.com/", _("You are not currently logged into YouTube.\n\nYou must have already signed up for a Google account and set it up for use with YouTube to continue. You can set up most accounts by using your browser to log into the YouTube site at least once."), host);
case "tumblr":
return new Shotwell.Tumblr.Tumblr(host);
default:
case "google-photos":
return new Shotwell.Google.Google("https://www.googleapis.com/auth/photoslibrary", _("You are not currently logged into Google Photos.\n\nYou must have already signed up for a Google account and set it up for use with Google Photos.\n\nYou will have to authorize Shotwell to link to your Google Photos account."), host);
default:
return null;
}
}
......
......@@ -444,6 +444,18 @@ public class Transaction {
public void add_argument(string name, string value) {
arguments += new Argument(name, value);
}
public void set_argument(string name, string value) {
foreach (var arg in arguments) {
if (arg.key == name) {
arg.value = value;
return;
}
}
add_argument(name, value);
}
public string? get_endpoint_url() {
return (endpoint_url != null) ? endpoint_url : parent_session.get_endpoint_url();
......
......@@ -42,7 +42,7 @@ public Gdk.Pixbuf[]? load_icon_set(GLib.File? icon_file) {
try {
icon = new Gdk.Pixbuf.from_file(icon_file.get_path());
} catch (Error err) {
warning("couldn't load icon set from %s.", icon_file.get_path());
warning("couldn't load icon set from %s: %s", icon_file.get_path(), err.message);
}
if (icon != null) {
......@@ -57,9 +57,10 @@ public Gdk.Pixbuf[]? load_icon_set(GLib.File? icon_file) {
public Gdk.Pixbuf[]? load_from_resource (string resource_path) {
Gdk.Pixbuf? icon = null;
try {
icon = new Gdk.Pixbuf.from_resource (resource_path);
debug ("Loading icon from %s", resource_path);
icon = new Gdk.Pixbuf.from_resource_at_scale (resource_path, 24, 24, true);
} catch (Error error) {
warning ("Couldn't load icon set from %s", resource_path);
warning ("Couldn't load icon set from %s: %s", resource_path, error.message);
}
if (icon != null) {
......
/* Copyright 2016 Software Freedom Conservancy Inc.
* Copyright 2019 Jens Georg <mail@jensge.org>
*
* This software is licensed under the GNU LGPL (version 2.1 or later).
* See the COPYING file in this distribution.
*/
namespace Publishing.GooglePhotos {
internal const string DEFAULT_ALBUM_NAME = N_("Shotwell Connect");
internal class Album {
public string name;
public string id;
public Album(string name, string id) {
this.name = name;
this.id = id;
}
}
internal class PublishingParameters {
public const int ORIGINAL_SIZE = -1;
private string? target_album_name;
private string? target_album_id;
private bool album_public;
private bool strip_metadata;
private int major_axis_size_pixels;
private int major_axis_size_selection_id;
private string user_name;
private Album[] albums;
private Spit.Publishing.Publisher.MediaType media_type;
public PublishingParameters() {
this.user_name = "[unknown]";
this.target_album_name = null;
this.target_album_id = null;
this.major_axis_size_selection_id = 0;
this.major_axis_size_pixels = ORIGINAL_SIZE;
this.album_public = false;
this.albums = null;
this.strip_metadata = false;
this.media_type = Spit.Publishing.Publisher.MediaType.PHOTO;
}
public string get_target_album_name() {
return target_album_name;
}
public void set_target_album_name(string target_album_name) {
this.target_album_name = target_album_name;
}
public void set_target_album_entry_id(string target_album_id) {
this.target_album_id = target_album_id;
}
public string get_target_album_entry_id() {
return this.target_album_id;
}
public string get_user_name() {
return user_name;
}
public void set_user_name(string user_name) {
this.user_name = user_name;
}
public Album[] get_albums() {
return albums;
}
public void set_albums(Album[] albums) {
this.albums = albums;
}
public void set_major_axis_size_pixels(int pixels) {
this.major_axis_size_pixels = pixels;
}
public int get_major_axis_size_pixels() {
return major_axis_size_pixels;
}
public void set_major_axis_size_selection_id(int selection_id) {
this.major_axis_size_selection_id = selection_id;
}
public int get_major_axis_size_selection_id() {
return major_axis_size_selection_id;
}
public void set_strip_metadata(bool strip_metadata) {
this.strip_metadata = strip_metadata;
}
public bool get_strip_metadata() {
return strip_metadata;
}
public void set_media_type(Spit.Publishing.Publisher.MediaType media_type) {
this.media_type = media_type;
}
public Spit.Publishing.Publisher.MediaType get_media_type() {
return media_type;
}
}
private class MediaCreationTransaction : Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction {
private const string ENDPOINT_URL = "https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate";
private string[] upload_tokens;
private string[] titles;
private string album_id;
public MediaCreationTransaction(Publishing.RESTSupport.GoogleSession session,
string[] upload_tokens,
string[] titles,
string album_id) {
base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.POST);
assert(upload_tokens.length == titles.length);
this.upload_tokens = upload_tokens;
this.album_id = album_id;
this.titles = titles;
}
public override void execute () throws Spit.Publishing.PublishingError {
var builder = new Json.Builder();
builder.begin_object();
builder.set_member_name("albumId");
builder.add_string_value(this.album_id);
builder.set_member_name("newMediaItems");
builder.begin_array();
for (var i = 0; i < this.upload_tokens.length; i++) {
builder.begin_object();
builder.set_member_name("description");
builder.add_string_value(this.titles[i]);
builder.set_member_name("simpleMediaItem");
builder.begin_object();
builder.set_member_name("uploadToken");
builder.add_string_value(this.upload_tokens[i]);
builder.end_object();
builder.end_object();
}
builder.end_array();
builder.end_object();
set_custom_payload(Json.to_string (builder.get_root (), false), "application/json");
base.execute();
}
}
private class AlbumCreationTransaction : Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction {
private const string ENDPOINT_URL = "https://photoslibrary.googleapis.com/v1/albums";
private string title;
public AlbumCreationTransaction(Publishing.RESTSupport.GoogleSession session,
string title) {
base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.POST);
this.title = title;
}
public override void execute () throws Spit.Publishing.PublishingError {
var builder = new Json.Builder();
builder.begin_object();
builder.set_member_name("album");
builder.begin_object();
builder.set_member_name("title");
builder.add_string_value(this.title);
builder.end_object();
builder.end_object();
set_custom_payload(Json.to_string (builder.get_root (), false), "application/json");
base.execute();
}
}
private class AlbumDirectoryTransaction : Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction {
private const string ENDPOINT_URL = "https://photoslibrary.googleapis.com/v1/albums";
private Album[] albums = new Album[0];
public AlbumDirectoryTransaction(Publishing.RESTSupport.GoogleSession session) {
base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.GET);
this.completed.connect(on_internal_continue_pagination);
}
public Album[] get_albums() {
return this.albums;
}
private void on_internal_continue_pagination() {
try {
debug(this.get_response());
var json = Json.from_string (this.get_response());
var object = json.get_object ();
var pagination_token_node = object.get_member ("nextPageToken");
var response_albums = object.get_member ("albums").get_array();
response_albums.foreach_element( (a, b, element) => {
var album = element.get_object();
var is_writable = album.get_member("isWriteable");
if (is_writable != null && is_writable.get_boolean())
albums += new Album(album.get_string_member("title"), album.get_string_member("id"));
});
if (pagination_token_node != null) {
this.set_argument ("pageToken", pagination_token_node.get_string ());
Signal.stop_emission_by_name (this, "completed");
Idle.add(() => {
try {
this.execute();
} catch (Spit.Publishing.PublishingError error) {
this.network_error(error);
}
return false;
});
}
} catch (Error error) {
critical ("Got error %s while trying to parse response, delegating", error.message);
this.network_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(error.message));
}
}
}
public class Publisher : Publishing.RESTSupport.GooglePublisher {
private Spit.Publishing.Authenticator authenticator;
private bool running = false;
private PublishingParameters publishing_parameters;
private Spit.Publishing.ProgressCallback progress_reporter;
public Publisher(Spit.Publishing.Service service,
Spit.Publishing.PluginHost host) {
base(service, host, "https://www.googleapis.com/auth/photoslibrary");
this.publishing_parameters = new PublishingParameters();
load_parameters_from_configuration_system(publishing_parameters);
var media_type = Spit.Publishing.Publisher.MediaType.NONE;
foreach(Spit.Publishing.Publishable p in host.get_publishables())
media_type |= p.get_media_type();
publishing_parameters.set_media_type(media_type);
}
private void load_parameters_from_configuration_system(PublishingParameters parameters) {
parameters.set_major_axis_size_selection_id(get_host().get_config_int("default-size", 0));
parameters.set_strip_metadata(get_host().get_config_bool("strip-metadata", false));
parameters.set_target_album_name(get_host().get_config_string("last-album", null));
}
private void save_parameters_to_configuration_system(PublishingParameters parameters) {
get_host().set_config_int("default-size", parameters.get_major_axis_size_selection_id());
get_host().set_config_bool("strip_metadata", parameters.get_strip_metadata());
get_host().set_config_string("last-album", parameters.get_target_album_name());
}
protected override void on_login_flow_complete() {
debug("EVENT: OAuth login flow complete.");
this.publishing_parameters.set_user_name (this.authenticator.get_authentication_parameter()["UserName"].get_string());
get_host().install_account_fetch_wait_pane();
get_host().set_service_locked(true);
AlbumDirectoryTransaction txn = new AlbumDirectoryTransaction(get_session());
txn.completed.connect(on_initial_album_fetch_complete);
txn.network_error.connect(on_initial_album_fetch_error);
try {
txn.execute();
} catch (Spit.Publishing.PublishingError error) {
on_initial_album_fetch_error(txn, error);
}
}
private void on_initial_album_fetch_complete(Publishing.RESTSupport.Transaction txn) {
txn.completed.disconnect(on_initial_album_fetch_complete);
txn.network_error.disconnect(on_initial_album_fetch_error);
if (!is_running())
return;
debug("EVENT: finished fetching album information.");
display_account_information((AlbumDirectoryTransaction)txn);
}
private void on_initial_album_fetch_error(Publishing.RESTSupport.Transaction txn,
Spit.Publishing.PublishingError error) {
txn.completed.disconnect(on_initial_album_fetch_complete);
txn.network_error.disconnect(on_initial_album_fetch_error);
if (!is_running())
return;
debug("EVENT: fetching album information failed; response = '%s'.",
txn.get_response());
if (txn.get_status_code() == 403 || txn.get_status_code() == 404) {
do_logout();
} else {
// If we get any other kind of error, we can't recover, so just post it to the user
get_host().post_error(error);
}
}
private void display_account_information(AlbumDirectoryTransaction txn) {
debug("ACTION: parsing album information");
this.publishing_parameters.set_albums(txn.get_albums());
show_publishing_options_pane();
}
private void show_publishing_options_pane() {
debug("ACTION: showing publishing options pane.");
var opts_pane = new PublishingOptionsPane(this.publishing_parameters, this.authenticator.can_logout());
opts_pane.publish.connect(on_publishing_options_publish);
opts_pane.logout.connect(on_publishing_options_logout);
get_host().install_dialog_pane(opts_pane);
get_host().set_service_locked(false);
}
private void on_publishing_options_logout() {
if (!is_running())
return;
debug("EVENT: user clicked 'Logout' in the publishing options pane.");
do_logout();
}
private void on_publishing_options_publish() {
if (!is_running())
return;
debug("EVENT: user clicked 'Publish' in the publishing options pane.");
save_parameters_to_configuration_system(publishing_parameters);
if (publishing_parameters.get_target_album_entry_id () != null) {
do_upload();
} else {
do_create_album();
}
}
private void do_create_album() {
debug("ACTION: Creating album");
assert(publishing_parameters.get_target_album_entry_id () == null);
get_host().set_service_locked(true);
var txn = new AlbumCreationTransaction(get_session(), publishing_parameters.get_target_album_name());
txn.completed.connect(on_album_create_complete);
txn.network_error.connect(on_album_create_error);
try {
txn.execute();
} catch (Spit.Publishing.PublishingError error) {
on_album_create_error(txn, error);
}
}
private void on_album_create_complete(Publishing.RESTSupport.Transaction txn) {
txn.completed.disconnect(on_album_create_complete);
txn.network_error.disconnect(on_album_create_error);
if (!is_running())
return;
debug("EVENT: finished creating album information: %s", txn.get_response());
try {
var node = Json.from_string(txn.get_response());
var object = node.get_object();
publishing_parameters.set_target_album_entry_id (object.get_string_member ("id"));
do_upload();
} catch (Error error) {
on_album_create_error(txn, new Spit.Publishing.PublishingError.MALFORMED_RESPONSE (error.message));
}
}
private void on_album_create_error(Publishing.RESTSupport.Transaction txn,
Spit.Publishing.PublishingError error) {
txn.completed.disconnect(on_initial_album_fetch_complete);
txn.network_error.disconnect(on_initial_album_fetch_error);
if (!is_running())
return;
debug("EVENT: creating album failed; response = '%s'.",
txn.get_response());
if (txn.get_status_code() == 403 || txn.get_status_code() == 404) {
do_logout();
} else {
// If we get any other kind of error, we can't recover, so just post it to the user
get_host().post_error(error);
}
}
protected override void do_logout() {
debug("ACTION: logging out user.");
get_session().deauthenticate();
if (this.authenticator.can_logout()) {
this.authenticator.logout();
this.authenticator.authenticate();
}
}
private void do_upload() {
debug("ACTION: uploading media items to remote server.");
get_host().set_service_locked(true);
progress_reporter = get_host().serialize_publishables(
publishing_parameters.get_major_axis_size_pixels(),
publishing_parameters.get_strip_metadata());
// Serialization is a long and potentially cancellable operation, so before we use
// the publishables, make sure that the publishing interaction is still running. If it
// isn't the publishing environment may be partially torn down so do a short-circuit
// return
if (!is_running())
return;
Spit.Publishing.Publishable[] publishables = get_host().get_publishables();
Uploader uploader = new Uploader(get_session(), publishables, publishing_parameters);
uploader.upload_complete.connect(on_upload_complete);
uploader.upload_error.connect(on_upload_error);
uploader.upload(on_upload_status_updated);
}
private void on_upload_status_updated(int file_number, double completed_fraction) {
if (!is_running())
return;
debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction);
assert(progress_reporter != null);
progress_reporter(file_number, completed_fraction);
}
private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader,
int num_published) {
if (!is_running())
return;
debug("EVENT: uploader reports upload complete; %d items published.", num_published);
uploader.upload_complete.disconnect(on_upload_complete);
uploader.upload_error.disconnect(on_upload_error);
var txn = new MediaCreationTransaction(get_session(),
((Uploader) uploader).upload_tokens,
((Uploader) uploader).titles,
publishing_parameters.get_target_album_entry_id());
txn.completed.connect(on_media_creation_complete);
txn.network_error.connect(on_media_creation_error);
try {
txn.execute();
} catch (Spit.Publishing.PublishingError error) {
on_media_creation_error(txn, error);
}
}
private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader,
Spit.Publishing.PublishingError err) {
if (!is_running())
return;
debug("EVENT: uploader reports upload error = '%s'.", err.message);
uploader.upload_complete.disconnect(on_upload_complete);
uploader.upload_error.disconnect(on_upload_error);
get_host().post_error(err);
}
private void on_media_creation_complete(Publishing.RESTSupport.Transaction txn) {
txn.completed.disconnect(on_media_creation_complete);
txn.network_error.disconnect(on_media_creation_error);
if (!is_running())
return;
debug("EVENT: Media creation reports success.");
get_host().set_service_locked(false);
get_host().install_success_pane();
}
private void on_media_creation_error(Publishing.RESTSupport.Transaction txn,
Spit.Publishing.PublishingError err) {
txn.completed.disconnect(on_media_creation_complete);
txn.network_error.disconnect(on_media_creation_error);
if (!is_running())
return;
debug("EVENT: Media creation reports error: %s", err.message);
get_host().post_error(err);
}
public override bool is_running() {
return running;
}
public override void start() {
debug("GooglePhotos.Publisher: start() invoked.");
if (is_running())
return;
running = true;
this.authenticator.authenticate();
}
public override void stop() {
debug("GooglePhotos.Publisher: stop() invoked.");
get_session().stop_transactions();