LP#1673870: Handle OverDrive ebook checkout and download
authorJeff Davis <jdavis@sitka.bclibraries.ca>
Tue, 4 Jul 2017 23:20:11 +0000 (16:20 -0700)
committerBill Erickson <berickxx@gmail.com>
Fri, 1 Sep 2017 20:06:50 +0000 (16:06 -0400)
The workflow for checking out and downloading a title via the OverDrive
API is relatively complex:

1. Check out a title.

2. Lock in a specific format for the checked-out title.  Once you lock
in a format, you can only download the title in that format -- except
that the browser-based OverDrive Read and OverDrive Listen formats are
always available (if supported for that title), even if you've locked in
another format.

3. Request a link for downloading the title in the specified format.
Download links are dynamically generated and only work for 60 seconds
from the time of your request.

To simplify the process, we require the user to lock in a format during
checkout.  Then, when the user clicks the Download button, we request a
download link; OverDrive responds with a URL, and we immediately
redirect the current browser tab/window to that URL.

A new API call, open-ils.ebook_api.title.get_download_link, has been
added for requesting the download link.  Since API calls are not
vendor-specific, we also add support for the new method in the test
module, complete with unit test.

Supplementary fixes:

- show spinner in My Account while loading from ebook API
- ensure session ID is available to ebook object during transactions
- fix display of ebook formats

Signed-off-by: Jeff Davis <jdavis@sitka.bclibraries.ca>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm
Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t
Open-ILS/src/templates/opac/parts/ebook_api/avail_js.tt2
Open-ILS/web/js/ui/default/opac/ebook_api/ebook.js
Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js

index d6961a8..bc968b5 100644 (file)
@@ -481,7 +481,7 @@ __PACKAGE__->register_method(
 # - barcode: patron barcode
 #
 sub do_xact {
-    my ($self, $conn, $auth, $session_id, $title_id, $barcode, $email) = @_;
+    my ($self, $conn, $auth, $session_id, $title_id, $barcode, $param) = @_;
 
     my $action;
     if ($self->api_name =~ /checkout/) {
@@ -509,9 +509,12 @@ sub do_xact {
 
     # handler method constructs and submits request (and handles any external authentication)
     my $res;
-    # place_hold has email as optional additional param
-    if ($action eq 'place_hold') {
-        $res = $handler->place_hold($title_id, $user_token, $email);
+    if ($action eq 'checkout') {
+        # checkout has format as optional additional param
+        $res = $handler->checkout($title_id, $user_token, $param);
+    } elsif ($action eq 'place_hold') {
+        # place_hold has email as optional additional param
+        $res = $handler->place_hold($title_id, $user_token, $param);
     } else {
         $res = $handler->$action($title_id, $user_token);
     }
@@ -845,4 +848,40 @@ __PACKAGE__->register_method(
     }
 );
 
+sub get_download_link {
+    my ($self, $conn, $auth, $session_id, $request_link) = @_;
+    my $handler = new_handler($session_id);
+    return $handler->do_get_download_link($request_link);
+}
+__PACKAGE__->register_method(
+    method => 'get_download_link',
+    api_name => 'open-ils.ebook_api.title.get_download_link',
+    api_level => 1,
+    argc => 3,
+    signature => {
+        desc => "Get download link for an OverDrive title that has been checked out",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'request_link',
+                desc => 'The URL used to request a download link',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Success: { url => "http://example.com/download-link" } / Failure: { error_msg => "Download link request failed." }',
+            type => 'hashref'
+        }
+    }
+);
+
 1;
index 9549fa4..b6997d1 100644 (file)
@@ -376,10 +376,13 @@ sub get_title_info {
     };
     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
         if ($res->{content}->{title}) {
-            return {
+            my $info = {
                 title  => $res->{content}->{title},
                 author => $res->{content}->{creators}[0]{name}
             };
+            # Append format information (useful for checkouts).
+            $info->{formats} = $self->get_formats($title_id);
+            return $info;
         } else {
             $logger->error("EbookAPI: OverDrive metadata lookup failed for $title_id");
         }
@@ -446,6 +449,20 @@ sub do_holdings_lookup {
     }
 
     # request available formats
+    $holdings->{formats} = $self->get_formats($title_id);
+
+    return $holdings;
+}
+
+# Returns a list of available formats for a given title.
+sub get_formats {
+    my ($self, $title_id) = @_;
+    $self->do_client_auth() if (!$self->{bearer_token});
+    $self->get_library_info() if (!$self->{collection_token});
+    my $collection_token = $self->{collection_token};
+
+    my $formats = [];
+
     my $format_req = {
         method  => 'GET',
         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
@@ -453,7 +470,7 @@ sub do_holdings_lookup {
     if (my $format_res = $self->handle_http_request($format_req, $self->{session_id})) {
         if ($format_res->{content}->{formats}) {
             foreach my $f (@{$format_res->{content}->{formats}}) {
-                push @{$holdings->{formats}}, $f->{name};
+                push @$formats, { id => $f->{id}, name => $f->{name} };
             }
         } else {
             $logger->info("EbookAPI: OverDrive holdings format request for title $title_id contained no format information");
@@ -462,7 +479,7 @@ sub do_holdings_lookup {
         $logger->error("EbookAPI: failed to retrieve OverDrive holdings formats for title $title_id");
     }
 
-    return $holdings;
+    return $formats;
 }
 
 # POST https://patron.api.overdrive.com/v1/patrons/me/checkouts
@@ -500,8 +517,17 @@ sub do_holdings_lookup {
 #     ],
 #     ...
 # }
+#
+# Our return value looks like this:
+# {
+#     due_date => "10/14/2013 10:56:00 AM",
+#     formats => [
+#         "ebook-overdrive" => "https://patron.api.overdrive.com/v1/patrons/me/checkouts/76C1B7D0-17F4-4C05-8397-C66C17411584/formats/ebook-overdrive/downloadlink?errorpageurl={errorpageurl}&odreadauthurl={odreadauthurl}",
+#         ...
+#     ]
+# }
 sub checkout {
-    my ($self, $title_id, $patron_token) = @_;
+    my ($self, $title_id, $patron_token, $format) = @_;
     my $request_content = {
         fields => [
             {
@@ -510,6 +536,9 @@ sub checkout {
             }
         ]
     };
+    if ($format) {
+        push @{$request_content->{fields}}, { name => 'formatType', value => $format };
+    }
     my $req = {
         method  => 'POST',
         uri     => $self->{circulation_base_uri} . "/patrons/me/checkouts",
@@ -517,7 +546,16 @@ sub checkout {
     };
     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
         if ($res->{content}->{expires}) {
-            return { due_date => $res->{content}->{expires} };
+            my $checkout = { due_date => $res->{content}->{expires} };
+            if (defined $res->{content}->{formats}) {
+                my $formats = {};
+                foreach my $f (@{$res->{content}->{formats}}) {
+                    my $ftype = $f->{formatType};
+                    $formats->{$ftype} = $f->{linkTemplates}->{downloadLink}->{href};
+                }
+                $checkout->{formats} = $formats;
+            }
+            return $checkout;
         }
         $logger->error("EbookAPI: checkout failed for OverDrive title $title_id");
         return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Unknown checkout error' ) };
@@ -637,12 +675,17 @@ sub get_patron_checkouts {
         foreach my $checkout (@{$res->{content}->{checkouts}}) {
             my $title_id = $checkout->{reserveId};
             my $title_info = $self->get_title_info($title_id);
-            # TODO get download URL - need to "lock in" a format first, see OD Checkouts API docs
+            my $formats = {};
+            foreach my $f (@{$checkout->{formats}}) {
+                my $ftype = $f->{formatType};
+                $formats->{$ftype} = $f->{linkTemplates}->{downloadLink}->{href};
+            };
             push @$checkouts, {
                 title_id => $title_id,
                 due_date => $checkout->{expires},
                 title => $title_info->{title},
-                author => $title_info->{author}
+                author => $title_info->{author},
+                formats => $formats
             }
         };
         $self->{checkouts} = $checkouts;
@@ -703,4 +746,33 @@ sub do_get_patron_xacts {
     return $self->handle_http_request($req, $self->{session_id});
 }
 
+# get download URL for checked-out title
+sub do_get_download_link {
+    my ($self, $request_link) = @_;
+    # Request links use the same domain as the circulation base URI, but they
+    # are apparently always plain HTTP.  The request link still works if you
+    # use HTTPS instead.  So, if our circulation base URI uses HTTPS, let's
+    # force the request link to HTTPS too, for two reasons:
+    # 1. A preference for HTTPS is implied by the library's circulation base
+    #    URI setting.
+    # 2. The base URI of the request link has to match the circulation base URI
+    #    (including the same protocol) in order for the handle_http_request()
+    #    method above to automatically re-authenticate the patron, if required.
+    if ($self->{circulation_base_uri} =~ /^https:/) {
+        $request_link =~ s/^http:/https:/;
+    }
+    my $req = {
+        method  => 'GET',
+        uri     => $request_link
+    };
+    if (my $res = $self->handle_http_request($req, $self->{session_id})) {
+        if ($res->{content}->{links}->{contentlink}->{href}) {
+            return { url => $res->{content}->{links}->{contentlink}->{href} };
+        }
+        return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Could not get content link' ) };
+    }
+    $logger->error("EbookAPI: no response received from OverDrive server");
+    return;
+}
+
 1;
index adab52c..31d7bf9 100644 (file)
@@ -272,6 +272,9 @@ sub checkout {
     # Patron ID or patron auth token, as returned by do_patron_auth().
     my $user_token = shift;
 
+    # Ebook format to be checked out (optional, not used here).
+    my $format = shift;
+
     # If checkout succeeds, the response is a hashref with the following fields:
     # - due_date
     # - xact_id (optional)
@@ -503,3 +506,23 @@ sub get_patron_holds {
     return $self->{holds};
 }
 
+sub do_get_download_link {
+    my $self = shift;
+    my $request_link = shift;
+
+    # For some vendors (e.g. OverDrive), the workflow is as follows:
+    #
+    # 1. Perform a checkout.
+    # 2. Checkout response contains a URL which we use to request a
+    #    format-specific download link for the checked-out title.
+    # 3. Submit a request to the request link.
+    # 4. Response contains a (temporary/dynamic) URL which the user
+    #    clicks on to download the ebook in the desired format.
+    #    
+    # For other vendors, the download link for a title is static and not
+    # format-dependent.  In that case, we just return the original request link
+    # (but ideally the UI will skip the download link request altogether, since
+    # it's superfluous in that case).
+
+    return $request_link;
+}
index 6c0aedb..0b81a4c 100644 (file)
@@ -1,6 +1,6 @@
 #!perl
 use strict; use warnings;
-use Test::More tests => 23; # XXX
+use Test::More tests => 24; # XXX
 use OpenILS::Utils::TestUtils;
 
 diag("Tests Ebook API");
@@ -154,6 +154,14 @@ my $checkout_req = $ebook_api->request(
 my $checkout = $checkout_req->recv->content;
 ok(exists $checkout->{due_date}, 'Ebook checked out');
 
+# open-ils.ebook_api.title.get_download_link
+my $request_link = 'http://example.com/ebookapi/t/003';
+my $download_link_req = $ebook_api->request(
+    'open-ils.ebook_api.title.get_download_link', $authtoken, $session_id, $request_link);
+my $download_link = $download_link_req->recv->content;
+# Test module just returns the original request_link as the response.
+ok($download_link eq $request_link, 'Received download link for ebook');
+
 # open-ils.ebook_api.renew
 my $renew_req = $ebook_api->request(
     'open-ils.ebook_api.renew', $authtoken, $session_id, '001', EBOOK_API_PATRON_USERNAME);
index 5a03dc5..216d2da 100644 (file)
@@ -29,7 +29,7 @@ dojo.addOnLoad(function() {
                             if (holdings.formats.length > 0) {
                                 var formats_ul = dojo.create("ul", null, ebook.rec_id + '_formats');
                                 dojo.forEach(holdings.formats, function(f) {
-                                    dojo.create("li", { innerHTML: f }, formats_ul);
+                                    dojo.create("li", { innerHTML: f.name }, formats_ul);
                                 });
                                 var status_node = dojo.byId(ebook.rec_id + '_status');
                                 var status_str = holdings.copies_available + ' of ' + holdings.copies_owned + ' available';
index 5119507..03814c4 100644 (file)
@@ -13,11 +13,10 @@ function Ebook(vendor, id) {
     this.avail;   // availability info for this title
     this.holdings = {}; // holdings info
     this.conns = {}; // references to Dojo event connection for performing actions with this ebook
-
 }
 
 Ebook.prototype.getDetails = function(callback) {
-    var ses = dojo.cookie(this.vendor);
+    var ses = this.ses || dojo.cookie(this.vendor);
     var ebook = this;
     new OpenSRF.ClientSession('open-ils.ebook_api').request({
         method: 'open-ils.ebook_api.title.details',
@@ -29,6 +28,8 @@ Ebook.prototype.getDetails = function(callback) {
                 console.log('title details response: ' + resp.content());
                 ebook.title = resp.content().title;
                 ebook.author = resp.content().author;
+                if (typeof resp.content().formats !== 'undefined')
+                    ebook.formats = resp.content().formats;
                 return callback(ebook);
             }
         }
@@ -36,7 +37,7 @@ Ebook.prototype.getDetails = function(callback) {
 }
 
 Ebook.prototype.getAvailability = function(callback) {
-    var ses = dojo.cookie(this.vendor);
+    var ses = this.ses || dojo.cookie(this.vendor);
     new OpenSRF.ClientSession('open-ils.ebook_api').request({
         method: 'open-ils.ebook_api.title.availability',
         params: [ ses, this.id ],
@@ -53,7 +54,7 @@ Ebook.prototype.getAvailability = function(callback) {
 }
 
 Ebook.prototype.getHoldings = function(callback) {
-    var ses = dojo.cookie(this.vendor);
+    var ses = this.ses || dojo.cookie(this.vendor);
     new OpenSRF.ClientSession('open-ils.ebook_api').request({
         method: 'open-ils.ebook_api.title.holdings',
         params: [ ses, this.id ],
@@ -70,11 +71,18 @@ Ebook.prototype.getHoldings = function(callback) {
 }
 
 Ebook.prototype.checkout = function(authtoken, patron_id, callback) {
-    var ses = dojo.cookie(this.vendor);
+    var ses = this.ses || dojo.cookie(this.vendor);
     var ebook = this;
+    // get selected checkout format (optional, used by OverDrive)
+    var checkout_format;
+    var format_selector = dojo.byId('checkout-format');
+    if (format_selector) {
+        checkout_format = format_selector.value;
+    }
+    // perform checkout
     new OpenSRF.ClientSession('open-ils.ebook_api').request({
         method: 'open-ils.ebook_api.checkout',
-        params: [ authtoken, ses, ebook.id, patron_id ],
+        params: [ authtoken, ses, ebook.id, patron_id, checkout_format ],
         async: true,
         oncomplete: function(r) {
             var resp = r.recv();
@@ -87,7 +95,7 @@ Ebook.prototype.checkout = function(authtoken, patron_id, callback) {
 }
 
 Ebook.prototype.placeHold = function(authtoken, patron_id, callback) {
-    var ses = dojo.cookie(this.vendor);
+    var ses = this.ses || dojo.cookie(this.vendor);
     var ebook = this;
     new OpenSRF.ClientSession('open-ils.ebook_api').request({
         method: 'open-ils.ebook_api.place_hold',
@@ -104,7 +112,7 @@ Ebook.prototype.placeHold = function(authtoken, patron_id, callback) {
 }
 
 Ebook.prototype.cancelHold = function(authtoken, patron_id, callback) {
-    var ses = dojo.cookie(this.vendor);
+    var ses = this.ses || dojo.cookie(this.vendor);
     var ebook = this;
     new OpenSRF.ClientSession('open-ils.ebook_api').request({
         method: 'open-ils.ebook_api.cancel_hold',
@@ -120,3 +128,43 @@ Ebook.prototype.cancelHold = function(authtoken, patron_id, callback) {
     }).send();
 }
 
+Ebook.prototype.download = function() {
+    var ses = this.ses || dojo.cookie(this.vendor);
+    var ebook = this;
+    var request_link;
+    var format_selector = dojo.byId('download-format');
+    if (!format_selector) {
+        console.log('could not find a specified format for download');
+        return;
+    } else {
+        request_link = format_selector.value;
+    }
+    // Request links include params like "errorpageurl={errorpageurl}"
+    // for redirecting the user if there's an error doing the download, etc.
+    // In these scenarios we always redirect the user to the current page.
+    // TODO: Add params to the current-page URL so that, if redirected, we
+    // can detect those params on page reload and show a useful message.
+    request_link = request_link.replace('{errorpageurl}', window.location.href);
+    request_link = request_link.replace('{odreadauthurl}', window.location.href);
+    // Now we're ready to request our download link.
+    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+        method: 'open-ils.ebook_api.title.get_download_link',
+        params: [ authtoken, ses, request_link ],
+        async: true,
+        oncomplete: function(r) {
+            var resp = r.recv();
+            if (resp) {
+                if (resp.content().error_msg) {
+                    console.log('download link request failed: ' + resp.content().error_msg);
+                } else if (resp.content().url) {
+                    var url = resp.content().url;
+                    console.log('download link received: ' + url);
+                    window.location = url;
+                } else {
+                    console.log('unknown error requesting download link');
+                }
+            }
+        }
+    }).send();
+}
+
index be520d5..56846bc 100644 (file)
@@ -20,6 +20,7 @@ var xacts = {
     holds_pending: [],
     holds_ready: []
 };
+var ebooks = [];
 
 // Ebook to perform actions on.
 var active_ebook;
@@ -62,6 +63,10 @@ function addTotalsToPage() {
 
 // Update current page with detailed transaction info, where appropriate.
 function addTransactionsToPage() {
+    // ensure active ebook has access to session ID to avoid scoping issues during transactions
+    if (active_ebook && typeof active_ebook.vendor !== 'undefined') {
+        active_ebook.ses = active_ebook.ses || dojo.cookie(active_ebook.vendor);
+    }
     if (myopac_page) {
         console.log('updating page with cached transaction details, if applicable');
         if (myopac_page === 'ebook_circs')
@@ -113,13 +118,25 @@ function updateCheckoutView() {
     } else {
         dojo.empty('ebook_circs_main_table_body');
         dojo.forEach(xacts.checkouts, function(x) {
-            var dl_link = '<a href="' + x.download_url + '">' + l_strings.download + '</a>';
+            var ebook = new Ebook(x.vendor, x.title_id);
             var tr = dojo.create("tr", null, dojo.byId('ebook_circs_main_table_body'));
             dojo.create("td", { innerHTML: x.title }, tr);
             dojo.create("td", { innerHTML: x.author }, tr);
             dojo.create("td", { innerHTML: x.due_date }, tr);
-            dojo.create("td", { innerHTML: dl_link}, tr);
+            var dl_td = dojo.create("td", null, tr);
+            if (x.download_url) {
+                dl_td.innerHTML = '<a href="' + x.download_url + '">' + l_strings.download + '</a>';
+            }
+            if (x.formats) {
+                var select = dojo.create("select", { id: "download-format" }, dl_td);
+                for (f in x.formats) {
+                    dojo.create("option", { value: x.formats[f], innerHTML: f }, select);
+                }
+                var button = dojo.create("input", { id: "download-button", type: "button", value: l_strings.download }, dl_td);
+                ebook.conns.download = dojo.connect(button, 'onclick', ebook, "download");
+            }
             // TODO: more actions (renew, checkin)
+            ebooks.push(ebook);
         });
         dojo.addClass('no_ebook_circs', "hidden");
         dojo.removeClass('ebook_circs_main', "hidden");
@@ -184,6 +201,8 @@ function updateHoldView() {
 
 // set up page for user to perform a checkout
 function getReadyForCheckout() {
+    if (typeof ebook_action.type === 'undefined')
+        return;
     if (typeof active_ebook === 'undefined') {
         console.log('No active ebook specified, cannot prepare for checkout');
         dojo.removeClass('ebook_checkout_failed', "hidden");
@@ -195,6 +214,12 @@ function getReadyForCheckout() {
             dojo.create("td", { innerHTML: ebook.author }, tr);
             dojo.create("td", null, tr);
             dojo.create("td", { id: "checkout-button-td" }, tr);
+            if (typeof active_ebook.formats !== 'undefined') {
+                var select = dojo.create("select", { id: "checkout-format" }, dojo.byId('checkout-button-td'));
+                dojo.forEach(active_ebook.formats, function(f) {
+                    dojo.create("option", { value: f.id, innerHTML: f.name }, select);
+                });
+            }
             var button = dojo.create("input", { id: "checkout-button", type: "button", value: l_strings.checkout }, dojo.byId('checkout-button-td'));
             ebook.conns.checkout = dojo.connect(button, 'onclick', "doCheckout");
             dojo.removeClass('ebook_circs_main', "hidden");
@@ -204,6 +229,8 @@ function getReadyForCheckout() {
 
 // set up page for user to place a hold
 function getReadyForHold() {
+    if (typeof ebook_action.type === 'undefined')
+        return;
     if (typeof active_ebook === 'undefined') {
         console.log('No active ebook specified, cannot prepare for hold');
         dojo.removeClass('ebook_hold_failed', "hidden");
@@ -238,28 +265,72 @@ function cleanupAfterAction() {
 
 // check out our active ebook
 function doCheckout() {
+    var ses = dojo.cookie(active_ebook.vendor); // required when inspecting checkouts for download_url
     active_ebook.checkout(authtoken, patron_id, function(resp) {
-        if (resp.due_date) {
-            console.log('Checkout succeeded!');
-            dojo.destroy('checkout-button');
-            dojo.removeClass('ebook_checkout_succeeded', "hidden");
-            // add our successful checkout to top of transaction cache
-            var new_xact = {
-                title_id: active_ebook.id,
-                title: active_ebook.title,
-                author: active_ebook.author,
-                due_date: resp.due_date,
-                download_url: '' // TODO - for OverDrive, user must "lock in" a format first!
-            };
-            xacts.checkouts.unshift(new_xact);
-            cleanupAfterAction();
-        } else {
+        if (resp.error_msg) {
             console.log('Checkout failed: ' + resp.error_msg);
             dojo.removeClass('ebook_checkout_failed', "hidden");
+            return;
+        }
+        console.log('Checkout succeeded!');
+        dojo.destroy('checkout-button');
+        dojo.destroy('checkout-format'); // remove optional format selector
+        dojo.removeClass('ebook_checkout_succeeded', "hidden");
+        // add our successful checkout to top of transaction cache
+        var new_xact = {
+            title_id: active_ebook.id,
+            title: active_ebook.title,
+            author: active_ebook.author,
+            due_date: resp.due_date,
+            finish: function() {
+                console.log('new_xact.finish()');
+                xacts.checkouts.unshift(this);
+                cleanupAfterAction();
+                // When we switch to jQuery, we can use .one() instead of .on(),
+                // obviating the need for an explicit disconnect here.
+                dojo.disconnect(active_ebook.conns.checkout);
+            }
+        };
+        if (resp.download_url) {
+            // Use download URL from checkout response, if available.
+            new_xact.download_url = resp.download_url;
+            dojo.create("a", { href: new_xact.download_url, innerHTML: l_strings.download }, dojo.byId('checkout-button-td'));
+            new_xact.finish();
+        } else if (typeof resp.formats !== 'undefined') {
+            // User must select download format from list of options.
+            var select = dojo.create("select", { id: "download-format" }, dojo.byId('checkout-button-td'));
+            for (f in resp.formats) {
+                dojo.create("option", { value: resp.formats[f], innerHTML: f }, select);
+            }
+            var button = dojo.create("input", { id: "download-button", type: "button", value: l_strings.download }, dojo.byId('checkout-button-td'));
+            active_ebook.conns.download = dojo.connect(button, 'onclick', active_ebook, "download");
+            new_xact.finish();
+        } else if (typeof resp.xact_id !== 'undefined') {
+            // No download URL provided by API checkout response.  Grab fresh
+            // list of user checkouts from API, find the just-completed
+            // checkout by transaction ID, and get the download URL from that.
+            // We call the OpenSRF method directly because Relation.getCheckouts()
+            // results in scoping issues when retrieving the vendor session cookie.
+            new_xact.xact_id = resp.xact_id;
+            new OpenSRF.ClientSession('open-ils.ebook_api').request({
+                method: 'open-ils.ebook_api.patron.get_checkouts',
+                params: [ authtoken, ses, patron_id ],
+                async: false,
+                oncomplete: function(r) {
+                    var resp = r.recv();
+                    if (resp) {
+                        dojo.forEach(resp.content(), function(x) {
+                            if (x.xact_id === new_xact.xact_id) {
+                                new_xact.download_url = x.download_url;
+                                dojo.create("a", { href: new_xact.download_url, innerHTML: l_strings.download }, dojo.byId('checkout-button-td'));
+                                return;
+                            }
+                        });
+                        new_xact.finish();
+                    }
+                }
+            }).send();
         }
-        // When we switch to jQuery, we can use .one() instead of .on(),
-        // obviating the need for an explicit disconnect here.
-        dojo.disconnect(active_ebook.conns.checkout);
     });
 }