Commit 74e259dc authored by Lucas Beeler's avatar Lucas Beeler

Migrates the YouTube connector to the new pluggable, publishing API.

parent afc218e7
......@@ -77,7 +77,6 @@ UNUNITIZED_SRC_FILES = \
Tag.vala \
TagPage.vala \
PiwigoConnector.vala \
YouTubeConnector.vala \
Screensaver.vala \
MimicManager.vala \
TrashPage.vala \
......
......@@ -14,6 +14,7 @@ SRC_FILES := \
FacebookPublishing.vala \
PicasaPublishing.vala \
FlickrPublishing.vala \
YouTubePublishing.vala \
RESTSupport.vala
include ../Makefile.plugin.mk
......
/* Copyright 2010-2011 Yorba Foundation
/* Copyright 2009-2011 Yorba Foundation
*
* This software is licensed under the GNU LGPL (version 2.1 or later).
* See the COPYING file in this distribution.
* See the COPYING file in this distribution.
*/
namespace YouTubeConnector {
private const string SERVICE_NAME = "YouTube";
public class YouTubeService : Object, Spit.Pluggable, Spit.Publishing.Service {
public int get_pluggable_interface(int min_host_interface, int max_host_interface) {
return Spit.negotiate_interfaces(min_host_interface, max_host_interface,
Spit.Publishing.CURRENT_INTERFACE);
}
public unowned string get_id() {
return "org.yorba.shotwell.publishing.youtube";
}
public unowned string get_pluggable_name() {
return "YouTube";
}
public void get_info(out Spit.PluggableInfo info) {
info.copyright = _("Copyright 2009-2011 Yorba Foundation");
info.translators = _("translator-credits");
info.version = _VERSION;
info.website_name = _("Visit the Yorba web site");
info.website_url = "http://www.yorba.org";
}
public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) {
return new Publishing.YouTube.YouTubePublisher(this, host);
}
public Spit.Publishing.Publisher.MediaType get_supported_media() {
return Spit.Publishing.Publisher.MediaType.VIDEO;
}
public void activation(bool enabled) {
}
}
namespace Publishing.YouTube {
private const string SERVICE_WELCOME_MESSAGE =
_("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.");
private const string DEVELOPER_KEY =
"AI39si5VEpzWK0z-pzo4fonEj9E4driCpEs9lK8y3HJsbbebIIRWqW3bIyGr42bjQv-N3siAfqVoM8XNmtbbp5x2gpbjiSAMTQ";
private const string CONFIG_NAME = "youtube";
private enum PrivacySetting {
VIDEO_PUBLIC,
VIDEO_UNLISTED,
VIDEO_PRIVATE
PUBLIC,
UNLISTED,
PRIVATE
}
private class PublishingParameters {
......@@ -30,229 +63,313 @@ private class PublishingParameters {
}
}
public class Capabilities : ServiceCapabilities {
public override string get_name() {
return SERVICE_NAME;
public class YouTubePublisher : Spit.Publishing.Publisher, GLib.Object {
private weak Spit.Publishing.PluginHost host = null;
private Spit.Publishing.ProgressCallback progress_reporter = null;
private weak Spit.Publishing.Service service = null;
private bool running = false;
private Session session;
private string? username = null;
private PublishingParameters parameters = null;
private string? channel_name = null;
public YouTubePublisher(Spit.Publishing.Service service,
Spit.Publishing.PluginHost host) {
this.service = service;
this.host = host;
this.session = new Session();
}
public override Spit.Publishing.Publisher.MediaType get_supported_media() {
return Spit.Publishing.Publisher.MediaType.VIDEO;
private string extract_channel_name(Xml.Node* document_root) throws
Spit.Publishing.PublishingError {
string result = "";
Xml.Node* doc_node_iter = null;
if (document_root->name == "feed")
doc_node_iter = document_root->children;
else if (document_root->name == "entry")
doc_node_iter = document_root;
else
throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(
"response root node isn't a <feed> or <entry>");
for ( ; doc_node_iter != null; doc_node_iter = doc_node_iter->next) {
if (doc_node_iter->name != "entry")
continue;
string name_val = null;
string url_val = null;
Xml.Node* channel_node_iter = doc_node_iter->children;
for ( ; channel_node_iter != null; channel_node_iter = channel_node_iter->next) {
if (channel_node_iter->name == "title") {
name_val = channel_node_iter->get_content();
} else if (channel_node_iter->name == "id") {
// we only want nodes in the default namespace -- the feed that we get back
// from Google also defines <entry> child nodes named <id> in the media
// namespace
if (channel_node_iter->ns->prefix != null)
continue;
url_val = channel_node_iter->get_content();
}
}
result = name_val;
break;
}
debug("YouTubePublisher: extracted channel name '%s' from response XML.", result);
return result;
}
public override ServiceInteractor factory(PublishingDialog host) {
return new Interactor(host);
internal string? get_persistent_username() {
return host.get_config_string("user_name", null);
}
internal string? get_persistent_auth_token() {
return host.get_config_string("auth_token", null);
}
internal void set_persistent_username(string username) {
host.set_config_string("user_name", username);
}
internal void set_persistent_auth_token(string auth_token) {
host.set_config_string("auth_token", auth_token);
}
}
public class Interactor : ServiceInteractor {
private Session session = null;
private string channel_name = null;
private PublishingParameters parameters = null;
private Uploader uploader = null;
private bool cancelled = false;
private ProgressPane progress_pane = null;
internal void invalidate_persistent_session() {
debug("invalidating persisted YouTube session.");
host.unset_config_key("user_name");
host.unset_config_key("auth_token");
}
internal bool is_persistent_session_available() {
return (get_persistent_username() != null && get_persistent_auth_token() != null);
}
public bool is_running() {
return running;
}
public Spit.Publishing.Service get_service() {
return service;
}
private void on_service_welcome_login() {
if (!is_running())
return;
debug("EVENT: user clicked 'Login' in welcome pane.");
public Interactor(PublishingDialog host) {
base(host);
session = new Session();
do_show_credentials_pane(CredentialsPane.Mode.INTRO);
}
// EVENT: triggered when the user clicks the "Go Back" button in the credentials capture pane
private void on_credentials_go_back() {
// ignore all events if the user has cancelled or we have and error situation
if (has_error() || cancelled)
if (!is_running())
return;
debug("EVENT: user clicked 'Go Back' in credentials pane.");
do_show_service_welcome_pane();
}
// EVENT: triggered when the network transaction that fetches the authentication token for
// the login account is completed successfully
// the response body contains the Auth and YouTubeUser fields
private void on_token_fetch_complete(RESTTransaction txn) {
private void on_credentials_login(string username, string password) {
if (!is_running())
return;
debug("EVENT: user clicked 'Login' in credentials pane.");
this.username = username;
do_network_login(username, password);
}
private void on_token_fetch_complete(Publishing.RESTSupport.Transaction txn) {
txn.completed.disconnect(on_token_fetch_complete);
txn.network_error.disconnect(on_token_fetch_error);
if (has_error() || cancelled)
if (!is_running())
return;
if (session.is_authenticated()) // ignore these events if the session is already auth'd
return;
debug("EVENT: network transaction to fetch token for login completed successfully.");
string auth_substring = txn.get_response().str("Auth=");
auth_substring = auth_substring.split("\n")[0];
auth_substring = auth_substring.chomp();
string auth_token = auth_substring.substring(5);
TokenFetchTransaction downcast_txn = (TokenFetchTransaction) txn;
session.authenticate(auth_token, downcast_txn.get_username());
do_fetch_account_information();
session.authenticated.connect(on_session_authenticated);
session.authenticate(auth_token, username);
}
// EVENT: triggered when the network transaction that fetches the authentication token for
// the login account fails
private void on_token_fetch_error(RESTTransaction bad_txn, PublishingError err) {
private void on_token_fetch_error(Publishing.RESTSupport.Transaction bad_txn,
Spit.Publishing.PublishingError err) {
bad_txn.completed.disconnect(on_token_fetch_complete);
bad_txn.network_error.disconnect(on_token_fetch_error);
if (has_error() || cancelled)
if (!is_running())
return;
if (session.is_authenticated()) // ignore these events if the session is already auth'd
return;
debug("EVENT: network transaction to fetch token for login failed; response = '%s'.",
bad_txn.get_response());
// HTTP error 403 is invalid authentication -- if we get this error during token fetch
// then we can just show the login screen again with a retry message; if we get any error
// other than 403 though, we can't recover from it, so just post the error to the user
if (bad_txn.get_status_code() == 403) {
if (bad_txn.get_response().contains("CaptchaRequired"))
do_show_credentials_capture_pane(CredentialsCapturePane.Mode.ADDITIONAL_SECURITY);
do_show_credentials_pane(CredentialsPane.Mode.ADDITIONAL_SECURITY);
else
do_show_credentials_capture_pane(CredentialsCapturePane.Mode.FAILED_RETRY);
do_show_credentials_pane(CredentialsPane.Mode.FAILED_RETRY);
}
else {
post_error(err);
host.post_error(err);
}
}
// EVENT: triggered when the user clicks "Login" in the credentials capture pane
private void on_credentials_login(string username, string password) {
if (has_error() || cancelled)
private void on_session_authenticated() {
session.authenticated.disconnect(on_session_authenticated);
if (!is_running())
return;
do_network_login(username, password);
debug("EVENT: an authenticated session has become available.");
do_save_auth_info();
do_fetch_account_information();
}
// EVENT: triggered when the user clicks "Login" in the service welcome pane
private void on_service_welcome_login() {
if (has_error() || cancelled)
private void on_initial_channel_fetch_complete(Publishing.RESTSupport.Transaction txn) {
txn.completed.disconnect(on_initial_channel_fetch_complete);
txn.network_error.disconnect(on_initial_channel_fetch_error);
if (!is_running())
return;
debug("EVENT: finished fetching account and channel information.");
do_parse_and_display_account_information((ChannelDirectoryTransaction) txn);
}
private void on_initial_channel_fetch_error(Publishing.RESTSupport.Transaction bad_txn,
Spit.Publishing.PublishingError err) {
bad_txn.completed.disconnect(on_initial_channel_fetch_complete);
bad_txn.network_error.disconnect(on_initial_channel_fetch_error);
if (!is_running())
return;
do_show_credentials_capture_pane(CredentialsCapturePane.Mode.INTRO);
debug("EVENT: fetching account and channel information failed; response = '%s'.",
bad_txn.get_response());
if (bad_txn.get_status_code() == 404) {
// if we get a 404 error (resource not found) on the initial channel fetch, then the
// user's channel feed doesn't exist -- this occurs when the user has a valid Google
// account but it hasn't yet been set up for use with YouTube. In this case, we
// re-display the credentials capture pane with an "account not set up" message.
// In addition, we deauthenticate the session. Deauth is neccessary because we
// did previously auth the user's account. If we get any other kind of error, we can't
// recover, so just post it to the user
session.deauthenticate();
do_show_credentials_pane(CredentialsPane.Mode.NOT_SET_UP);
} else if (bad_txn.get_status_code() == 403) {
// if we get a 403 error (authentication failed) then we need to return to the login
// screen because the user's auth token is no longer valid and he or she needs to
// login again to obtain a new one
session.deauthenticate();
do_show_credentials_pane(CredentialsPane.Mode.INTRO);
} else {
host.post_error(err);
}
}
// EVENT: triggered when the user clicks "Logout" in the publishing options pane
private void on_publishing_options_logout() {
if (has_error() || cancelled)
if (!is_running())
return;
debug("EVENT: user clicked 'Logout' in the publishing options pane.");
session.deauthenticate();
invalidate_persistent_session();
do_show_service_welcome_pane();
}
// EVENT: triggered when the user clicks "Publish" in the publishing options pane
private void on_publishing_options_publish(PublishingParameters parameters) {
if (has_error() || cancelled)
if (!is_running())
return;
debug("EVENT: user clicked 'Publish' in the publishing options pane.");
this.parameters = parameters;
do_upload();
}
// EVENT: triggered when the network transaction that fetches the user's account information
// is completed successfully
private void on_initial_album_fetch_complete(RESTTransaction txn) {
txn.completed.disconnect(on_initial_album_fetch_complete);
txn.network_error.disconnect(on_initial_album_fetch_error);
if (has_error() || cancelled)
private void on_upload_status_updated(int file_number, double completed_fraction) {
if (!is_running())
return;
do_parse_and_display_account_information((AlbumDirectoryTransaction) txn);
}
debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction);
// EVENT: triggered when the network transaction that fetches the user's account information
// fails
private void on_initial_album_fetch_error(RESTTransaction bad_txn, PublishingError err) {
bad_txn.completed.disconnect(on_initial_album_fetch_complete);
bad_txn.network_error.disconnect(on_initial_album_fetch_error);
assert(progress_reporter != null);
if (has_error() || cancelled)
progress_reporter(file_number, completed_fraction);
}
private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader,
int num_published) {
if (!is_running())
return;
if (bad_txn.get_status_code() == 404) {
// if we get a 404 error (resource not found) on the initial album fetch, then the
// user's album feed doesn't exist -- this occurs when the user has a valid Google
// account but it hasn't yet been set up for use with YouTube. In this case, we
// re-display the credentials capture pane with an "account not set up" message.
// In addition, we deauthenticate the session. Deauth is neccessary because we
// did previously auth the user's account. If we get any other kind of error, we can't
// recover, so just post it to the user
session.deauthenticate();
do_show_credentials_capture_pane(CredentialsCapturePane.Mode.NOT_SET_UP);
} else if (bad_txn.get_status_code() == 403 || bad_txn.get_status_code() == 401) {
// if we get a 403 error (authentication failed) or 401 (token expired) then we need
// to return to the login screen because the user's auth token is no longer valid and
// he or she needs to login again to obtain a new one
session.deauthenticate();
do_show_credentials_capture_pane(CredentialsCapturePane.Mode.INTRO);
} else {
post_error(err);
}
}
debug("EVENT: uploader reports upload complete; %d items published.", num_published);
// EVENT: triggered when the batch uploader reports that at least one of the network
// transactions encapsulating uploads has completed successfully
private void on_upload_complete(BatchUploader uploader, int num_published) {
uploader.upload_complete.disconnect(on_upload_complete);
uploader.upload_error.disconnect(on_upload_error);
uploader.status_updated.disconnect(progress_pane.set_status);
// TODO: add a descriptive, translatable error message string here
if (num_published == 0)
post_error(new PublishingError.LOCAL_FILE_ERROR(""));
if (has_error() || cancelled)
return;
do_show_success_pane();
}
// EVENT: triggered when the batch uploader reports that at least one of the network
// transactions encapsulating uploads has caused a network error
private void on_upload_error(BatchUploader uploader, PublishingError err) {
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);
uploader.status_updated.disconnect(progress_pane.set_status);
if (has_error() || cancelled)
return;
post_error(err);
host.post_error(err);
}
// ACTION: display the service welcome pane in the publishing dialog
private void do_show_service_welcome_pane() {
LoginWelcomePane service_welcome_pane = new LoginWelcomePane(SERVICE_WELCOME_MESSAGE);
service_welcome_pane.login_requested.connect(on_service_welcome_login);
debug("ACTION: showing service welcome pane.");
get_host().unlock_service();
get_host().set_cancel_button_mode();
get_host().install_pane(service_welcome_pane);
host.install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_service_welcome_login);
}
// ACTION: display the credentials capture pane in the publishing dialog; the credentials
// capture pane can be displayed in different "modes" that display different
// messages to the user
private void do_show_credentials_capture_pane(CredentialsCapturePane.Mode mode) {
CredentialsCapturePane creds_pane = new CredentialsCapturePane(this, mode);
private void do_show_credentials_pane(CredentialsPane.Mode mode) {
debug("ACTION: showing credentials capture pane in %s mode.", mode.to_string());
CredentialsPane creds_pane = new CredentialsPane(host, mode);
creds_pane.go_back.connect(on_credentials_go_back);
creds_pane.login.connect(on_credentials_login);
get_host().unlock_service();
get_host().set_cancel_button_mode();
get_host().install_pane(creds_pane);
host.install_dialog_pane(creds_pane);
}
// ACTION: given a username and password, run a REST transaction over the network to
// log a user into the YouTube service
private void do_network_login(string username, string password) {
get_host().install_pane(new LoginWaitPane());
get_host().lock_service();
get_host().set_cancel_button_mode();
debug("ACTION: running network login transaction for user = '%s'.", username);
host.install_login_wait_pane();
TokenFetchTransaction fetch_trans = new TokenFetchTransaction(session, username, password);
fetch_trans.network_error.connect(on_token_fetch_error);
......@@ -260,170 +377,375 @@ public class Interactor : ServiceInteractor {
try {
fetch_trans.execute();
} catch (PublishingError err) {
post_error(err);
} catch (Spit.Publishing.PublishingError err) {
// 403 errors are recoverable, so don't post the error to our host immediately;
// instead, try to recover from it
on_token_fetch_error(fetch_trans, err);
}
}
private void do_save_auth_info() {
debug("ACTION: saving authentication information to configuration system.");
assert(session.is_authenticated());
set_persistent_auth_token(session.get_auth_token());
set_persistent_username(session.get_username());
}
// ACTION: run a REST transaction over the network to fetch the user's account information
// While the network transaction is running, display a wait pane with an info message
// in the publishing dialog.
private void do_fetch_account_information() {
get_host().install_pane(new AccountFetchWaitPane());
debug("ACTION: fetching account and channel information.");
get_host().lock_service();
get_host().set_cancel_button_mode();
host.install_account_fetch_wait_pane();
host.set_service_locked(true);
AlbumDirectoryTransaction directory_trans =
new AlbumDirectoryTransaction(session);
directory_trans.network_error.connect(on_initial_album_fetch_error);
directory_trans.completed.connect(on_initial_album_fetch_complete);
ChannelDirectoryTransaction directory_trans =
new ChannelDirectoryTransaction(session);
directory_trans.network_error.connect(on_initial_channel_fetch_error);
directory_trans.completed.connect(on_initial_channel_fetch_complete);
try {
directory_trans.execute();
} catch (PublishingError err) {
post_error(err);
} catch (Spit.Publishing.PublishingError err) {
// don't just post the error and stop publishing -- 404 and 403 errors are
// recoverable
on_initial_channel_fetch_error(directory_trans, err);
}
}
private void do_parse_and_display_account_information(ChannelDirectoryTransaction transaction) {
debug("ACTION: fetching account and channel information.");
Publishing.RESTSupport.XmlDocument response_doc;
try {
response_doc = Publishing.RESTSupport.XmlDocument.parse_string(
transaction.get_response(), ChannelDirectoryTransaction.validate_xml);
} catch (Spit.Publishing.PublishingError err) {
host.post_error(err);
return;
}
try {
channel_name = extract_channel_name(response_doc.get_root_node());
} catch (Spit.Publishing.PublishingError err) {
host.post_error(err);
return;
}
do_show_publishing_options_pane();
}
// ACTION: display the publishing options pane in the publishing dialog
private void do_show_publishing_options_pane() {
PublishingOptionsPane opts_pane = new PublishingOptionsPane(this, channel_name);
debug("ACTION: showing publishing options pane.");
PublishingOptionsPane opts_pane = new PublishingOptionsPane(host, username, channel_name);
opts_pane.publish.connect(on_publishing_options_publish);
opts_pane.logout.connect(on_publishing_options_logout);
get_host().install_pane(opts_pane);
host.install_dialog_pane(opts_pane);
get_host().unlock_service();
get_host().set_cancel_button_mode();
host.set_service_locked(false);
}
// ACTION: run a REST transaction over the network to upload the user's videos to the remote
// endpoint. Display a progress pane while the transaction is running.
private void do_upload() {
progress_pane = new ProgressPane();
get_host().install_pane(progress_pane);
debug("ACTION: uploading media items to remote server.");
host.set_service_locked(true);
get_host().lock_service();
get_host().set_cancel_button_mode();
progress_reporter = host.serialize_publishables(-1);
Video[] videos = get_host().get_videos();
uploader = new Uploader(session, parameters, videos);
Spit.Publishing.Publishable[] publishables = host.get_publishables();
Uploader uploader = new Uploader(session, publishables, parameters);
uploader.upload_complete.connect(on_upload_complete);
uploader.upload_error.connect(on_upload_error);
uploader.status_updated.connect(progress_pane.set_status);
uploader.upload();
uploader.upload(on_upload_status_updated);
}
// ACTION: the response body of 'transaction' is an XML document that describes the user's
// YouTube account (e.g. the names of the user's albums and their
// REST URLs). Parse the response body of 'transaction' and display the publishing
// options pane with its widgets populated such that they reflect the user's
// account info
private void do_parse_and_display_account_information(AlbumDirectoryTransaction transaction) {
RESTXmlDocument response_doc;
try {
response_doc = RESTXmlDocument.parse_string(transaction.get_response(),
AlbumDirectoryTransaction.check_response);
} catch (PublishingError err) {
post_error(err);
return;
}
private void do_show_success_pane() {
debug("ACTION: showing success pane.");
try {
channel_name = extract_albums(response_doc.get_root_node());
} catch (PublishingError err) {
post_error(err);
host.set_service_locked(false);
host.install_success_pane();
}
public void start() {
if (is_running())
return;
if (host == null)
error("YouTubePublisher: start( ): can't start; this publisher is not restartable.");
debug("YouTubePublisher: starting interaction.");
running = true;
if (is_persistent_session_available()) {
username = get_persistent_username();
session.authenticate(get_persistent_auth_token(), get_persistent_username());
do_fetch_account_information();
} else {
do_show_service_welcome_pane();
}
}
do_show_publishing_options_pane();
public void stop() {
debug("YouTubePublisher: stop( ) invoked.");
host = null;
running = false;
}
}
// ACTION: display the success pane in the publishing dialog
private void do_show_success_pane() {
get_host().unlock_service();
get_host().set_close_button_mode();
internal class Session : Publishing.RESTSupport.Session {
private string? auth_token = null;
private string? username = null;
public Session() {
}
public override bool is_authenticated() {
return (auth_token != null);
}
public void authenticate(string auth_token, string username) {
this.auth_token = auth_token;
this.username = username;
notify_authenticated();
}
public void deauthenticate() {
auth_token