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)
committerJeff Davis <jdavis@sitka.bclibraries.ca>
Fri, 14 Jul 2017 19:12:12 +0000 (12:12 -0700)
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.

Signed-off-by: Jeff Davis <jdavis@sitka.bclibraries.ca>
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/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..b3d0cab 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();
+            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();
+
+    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, $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,21 @@ 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) = @_;
+    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 5119507..881f8c3 100644 (file)
@@ -13,7 +13,6 @@ 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) {
@@ -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);
             }
         }
@@ -72,9 +73,16 @@ Ebook.prototype.getHoldings = function(callback) {
 Ebook.prototype.checkout = function(authtoken, patron_id, callback) {
     var 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();
@@ -120,3 +128,34 @@ Ebook.prototype.cancelHold = function(authtoken, patron_id, callback) {
     }).send();
 }
 
+Ebook.prototype.download = function(authtoken) {
+    var 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;
+    }
+    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) {
+                    console.log('download link received: ' + resp.content().url);
+                    //window.location = resp.url;
+                } else {
+                    console.log('unknown error requesting download link');
+                }
+            }
+        }
+    }).send();
+}
+
index be520d5..a371d7f 100644 (file)
@@ -113,12 +113,23 @@ 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>';
+            x.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);
+                x.ebook.conns.download = dojo.connect(button, 'onclick', x.ebook, "download");
+            }
             // TODO: more actions (renew, checkin)
         });
         dojo.addClass('no_ebook_circs', "hidden");
@@ -195,6 +206,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, innerHTML: f }, 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");
@@ -242,15 +259,27 @@ function doCheckout() {
         if (resp.due_date) {
             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,
-                download_url: '' // TODO - for OverDrive, user must "lock in" a format first!
+                due_date: resp.due_date
             };
+            if (resp.download_url) {
+                new_xact.download_url = resp.download_url;
+            }
+            if (typeof resp.formats !== 'undefined') {
+                new_xact.ebook = new Ebook(active_ebook.vendor, active_ebook.title_id);
+                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'));
+                new_xact.ebook.conns.download = dojo.connect(button, 'onclick', new_xact.ebook, "download");
+            }
             xacts.checkouts.unshift(new_xact);
             cleanupAfterAction();
         } else {