#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import sys
import uuid
from base64 import b64decode , b64encode
from datetime import datetime
from time import gmtime , strftime
from jsonschema import validate , exceptions
from flask import Blueprint , request , make_response , jsonify , json , current_app , url_for
from flask_login import login_required
from sqlalchemy import func
from . import config , logger , kobo_auth , db , helper
from . web import download_required
#TODO: Test more formats :) .
KOBO_SUPPORTED_FORMATS = { " KEPUB " }
kobo = Blueprint ( " kobo " , __name__ , url_prefix = " /kobo/<auth_token> " )
kobo_auth . disable_failed_auth_redirect_for_blueprint ( kobo )
kobo_auth . register_url_value_preprocessor ( kobo )
log = logger . create ( )
def b64encode_json ( json_data ) :
if sys . version_info < ( 3 , 0 ) :
return b64encode ( json . dumps ( json_data ) )
else :
return b64encode ( json . dumps ( json_data ) . encode ( ) )
# Python3 has a timestamp() method we could be calling, however it's not avaiable in python2.
def to_epoch_timestamp ( datetime_object ) :
return ( datetime_object - datetime ( 1970 , 1 , 1 ) ) . total_seconds ( )
class SyncToken :
""" The SyncToken is used to persist state accross requests.
When serialized over the response headers , the Kobo device will propagate the token onto following requests to the service .
As an example use - case , the SyncToken is used to detect books that have been added to the library since the last time the device synced to the server .
Attributes :
books_last_created : Datetime representing the newest book that the device knows about .
books_last_modified : Datetime representing the last modified book that the device knows about .
"""
SYNC_TOKEN_HEADER = " x-kobo-synctoken "
VERSION = " 1-0-0 "
MIN_VERSION = " 1-0-0 "
token_schema = {
" type " : " object " ,
" properties " : { " version " : { " type " : " string " } , " data " : { " type " : " object " } , } ,
}
# This Schema doesn't contain enough information to detect and propagate book deletions from Calibre to the device.
# A potential solution might be to keep a list of all known book uuids in the token, and look for any missing from the db.
data_schema_v1 = {
" type " : " object " ,
" properties " : {
" raw_kobo_store_token " : { " type " : " string " } ,
" books_last_modified " : { " type " : " string " } ,
" books_last_created " : { " type " : " string " } ,
} ,
}
def __init__ (
self ,
raw_kobo_store_token = " " ,
books_last_created = datetime . min ,
books_last_modified = datetime . min ,
) :
self . raw_kobo_store_token = raw_kobo_store_token
self . books_last_created = books_last_created
self . books_last_modified = books_last_modified
@staticmethod
def from_headers ( headers ) :
sync_token_header = headers . get ( SyncToken . SYNC_TOKEN_HEADER , " " )
if sync_token_header == " " :
return SyncToken ( )
# On the first sync from a Kobo device, we may receive the SyncToken
# from the official Kobo store. Without digging too deep into it, that
# token is of the form [b64encoded blob].[b64encoded blob 2]
if " . " in sync_token_header :
return SyncToken ( raw_kobo_store_token = sync_token_header )
try :
sync_token_json = json . loads (
b64decode ( sync_token_header + " = " * ( - len ( sync_token_header ) % 4 ) )
)
validate ( sync_token_json , SyncToken . token_schema )
if sync_token_json [ " version " ] < SyncToken . MIN_VERSION :
raise ValueError
data_json = sync_token_json [ " data " ]
validate ( sync_token_json , SyncToken . data_schema_v1 )
except ( exceptions . ValidationError , ValueError ) as e :
log . error ( " Sync token contents do not follow the expected json schema. " )
return SyncToken ( )
raw_kobo_store_token = data_json [ " raw_kobo_store_token " ]
try :
books_last_modified = datetime . utcfromtimestamp (
data_json [ " books_last_modified " ]
)
books_last_created = datetime . utcfromtimestamp (
data_json [ " books_last_created " ]
)
except TypeError :
log . error ( " SyncToken timestamps don ' t parse to a datetime. " )
return SyncToken ( raw_kobo_store_token = raw_kobo_store_token )
return SyncToken (
raw_kobo_store_token = raw_kobo_store_token ,
books_last_created = books_last_created ,
books_last_modified = books_last_modified ,
)
def to_headers ( self , headers ) :
headers [ SyncToken . SYNC_TOKEN_HEADER ] = self . build_sync_token ( )
def build_sync_token ( self ) :
token = {
" version " : SyncToken . VERSION ,
" data " : {
" raw_kobo_store_token " : self . raw_kobo_store_token ,
" books_last_modified " : to_epoch_timestamp ( self . books_last_modified ) ,
" books_last_created " : to_epoch_timestamp ( self . books_last_created ) ,
} ,
}
return b64encode_json ( token )
@kobo.route ( " /v1/library/sync " )
@login_required
@download_required
def HandleSyncRequest ( ) :
sync_token = SyncToken . from_headers ( request . headers )
log . info ( " Kobo library sync request received. " )
# TODO: Limit the number of books return per sync call, and rely on the sync-continuatation header
# instead so that the device triggers another sync.
new_books_last_modified = sync_token . books_last_modified
new_books_last_created = sync_token . books_last_created
entitlements = [ ]
# sqlite gives unexpected results when performing the last_modified comparison without the datetime cast.
# It looks like it's treating the db.Books.last_modified field as a string and may fail
# the comparison because of the +00:00 suffix.
changed_entries = (
db . session . query ( db . Books )
. join ( db . Data )
. filter ( func . datetime ( db . Books . last_modified ) > sync_token . books_last_modified )
. filter ( db . Data . format . in_ ( KOBO_SUPPORTED_FORMATS ) )
. all ( )
)
for book in changed_entries :
entitlement = {
" BookEntitlement " : create_book_entitlement ( book ) ,
" BookMetadata " : get_metadata ( book ) ,
" ReadingState " : reading_state ( book ) ,
}
if book . timestamp > sync_token . books_last_created :
entitlements . append ( { " NewEntitlement " : entitlement } )
else :
entitlements . append ( { " ChangedEntitlement " : entitlement } )
new_books_last_modified = max (
book . last_modified , sync_token . books_last_modified
)
new_books_last_created = max ( book . timestamp , sync_token . books_last_modified )
sync_token . books_last_created = new_books_last_created
sync_token . books_last_modified = new_books_last_modified
# Missing feature: Detect server-side book deletions.
# Missing feature: Join the response with results from the official Kobo store so that users can still buy and access books from the device store (particularly while on-the-road).
response = make_response ( jsonify ( entitlements ) )
sync_token . to_headers ( response . headers )
response . headers [ " x-kobo-sync-mode " ] = " delta "
response . headers [ " x-kobo-apitoken " ] = " e30= "
return response
@kobo.route ( " /v1/library/<book_uuid>/metadata " )
@login_required
@download_required
def HandleMetadataRequest ( book_uuid ) :
log . info ( " Kobo library metadata request received for book %s " % book_uuid )
book = db . session . query ( db . Books ) . filter ( db . Books . uuid == book_uuid ) . first ( )
if not book or not book . data :
log . info ( u " Book %s not found in database " , book_uuid )
return make_response ( " Book not found in database. " , 404 )
metadata = get_metadata ( book )
return jsonify ( [ metadata ] )
def get_download_url_for_book ( book , book_format ) :
return url_for (
" web.download_link " ,
book_id = book . id ,
book_format = book_format . lower ( ) ,
_external = True ,
)
def create_book_entitlement ( book ) :
book_uuid = book . uuid
return {
" Accessibility " : " Full " ,
" ActivePeriod " : { " From " : current_time ( ) , } ,
" Created " : book . timestamp ,
" CrossRevisionId " : book_uuid ,
" Id " : book_uuid ,
" IsHiddenFromArchive " : False ,
" IsLocked " : False ,
# Setting this to true removes from the device.
" IsRemoved " : False ,
" LastModified " : book . last_modified ,
" OriginCategory " : " Imported " ,
" RevisionId " : book_uuid ,
" Status " : " Active " ,
}
def current_time ( ) :
return strftime ( " % Y- % m- %d T % H: % M: % SZ " , gmtime ( ) )
def get_description ( book ) :
if not book . comments :
return None
return book . comments [ 0 ] . text
# TODO handle multiple authors
def get_author ( book ) :
if not book . authors :
return None
return book . authors [ 0 ] . name
def get_publisher ( book ) :
if not book . publishers :
return None
return book . publishers [ 0 ] . name
def get_series ( book ) :
if not book . series :
return None
return book . series [ 0 ] . name
def get_metadata ( book ) :
download_urls = [ ]
for book_data in book . data :
if book_data . format in KOBO_SUPPORTED_FORMATS :
download_urls . append (
{
" Format " : book_data . format ,
" Size " : book_data . uncompressed_size ,
" Url " : get_download_url_for_book ( book , book_data . format ) ,
# "DrmType": "None", # Not required
" Platform " : " Android " , # Required field.
}
)
book_uuid = book . uuid
metadata = {
" Categories " : [ " 00000000-0000-0000-0000-000000000001 " , ] ,
" Contributors " : get_author ( book ) ,
" CoverImageId " : book_uuid ,
" CrossRevisionId " : book_uuid ,
" CurrentDisplayPrice " : { " CurrencyCode " : " USD " , " TotalAmount " : 0 } ,
" CurrentLoveDisplayPrice " : { " TotalAmount " : 0 } ,
" Description " : get_description ( book ) ,
" DownloadUrls " : download_urls ,
" EntitlementId " : book_uuid ,
" ExternalIds " : [ ] ,
" Genre " : " 00000000-0000-0000-0000-000000000001 " ,
" IsEligibleForKoboLove " : False ,
" IsInternetArchive " : False ,
" IsPreOrder " : False ,
" IsSocialEnabled " : True ,
" Language " : " en " ,
" PhoneticPronunciations " : { } ,
" PublicationDate " : " 2019-02-03T00:25:03.0000000Z " , # current_time(),
" Publisher " : { " Imprint " : " " , " Name " : get_publisher ( book ) , } ,
" RevisionId " : book_uuid ,
" Title " : book . title ,
" WorkId " : book_uuid ,
}
if get_series ( book ) :
if sys . version_info < ( 3 , 0 ) :
name = get_series ( book ) . encode ( " utf-8 " )
else :
name = get_series ( book )
metadata [ " Series " ] = {
" Name " : get_series ( book ) ,
" Number " : book . series_index ,
" NumberFloat " : float ( book . series_index ) ,
# Get a deterministic id based on the series name.
" Id " : uuid . uuid3 ( uuid . NAMESPACE_DNS , name ) ,
}
return metadata
def reading_state ( book ) :
# TODO: Implement
reading_state = {
# "StatusInfo": {
# "LastModified": get_single_cc_value(book, "lastreadtimestamp"),
# "Status": get_single_cc_value(book, "reading_status"),
# }
# TODO: CurrentBookmark, Location
}
return reading_state
@kobo.route (
" /<book_uuid>/<horizontal>/<vertical>/<jpeg_quality>/<monochrome>/image.jpg "
)
def HandleCoverImageRequest ( book_uuid , horizontal , vertical , jpeg_quality , monochrome ) :
book_cover = helper . get_book_cover_with_uuid (
book_uuid , use_generic_cover_on_failure = False
)
if not book_cover :
return make_response ( )
return book_cover
@kobo.route ( " " )
def TopLevelEndpoint ( ) :
return make_response ( jsonify ( { } ) )
@kobo.route ( " /v1/user/profile " )
@kobo.route ( " /v1/user/loyalty/benefits " )
@kobo.route ( " /v1/analytics/gettests/ " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/user/wishlist " )
@kobo.route ( " /v1/user/<dummy> " )
@kobo.route ( " /v1/user/recommendations " )
@kobo.route ( " /v1/products/<dummy> " )
@kobo.route ( " /v1/products/<dummy>/nextread " )
@kobo.route ( " /v1/products/featured/<dummy> " )
@kobo.route ( " /v1/products/featured/ " )
@kobo.route ( " /v1/library/<dummy> " , methods = [ " DELETE " , " GET " ] ) # TODO: implement
def HandleDummyRequest ( dummy = None ) :
return make_response ( jsonify ( { } ) )
@kobo.route ( " /v1/auth/device " , methods = [ " POST " ] )
def HandleAuthRequest ( ) :
# This AuthRequest isn't used for most of our usecases.
response = make_response (
jsonify (
{
" AccessToken " : " abcde " ,
" RefreshToken " : " abcde " ,
" TokenType " : " Bearer " ,
" TrackingId " : " abcde " ,
" UserKey " : " abcdefgeh " ,
}
)
)
return response
@kobo.route ( " /v1/initialization " )
def HandleInitRequest ( ) :
resources = NATIVE_KOBO_RESOURCES (
calibre_web_url = url_for ( " web.index " , _external = True ) . strip ( " / " )
)
response = make_response ( jsonify ( { " Resources " : resources } ) )
response . headers [ " x-kobo-apitoken " ] = " e30= "
return response
def NATIVE_KOBO_RESOURCES ( calibre_web_url ) :
return {
" account_page " : " https://secure.kobobooks.com/profile " ,
" account_page_rakuten " : " https://my.rakuten.co.jp/ " ,
" add_entitlement " : " https://storeapi.kobo.com/v1/library/ {RevisionIds} " ,
" affiliaterequest " : " https://storeapi.kobo.com/v1/affiliate " ,
" audiobook_subscription_orange_deal_inclusion_url " : " https://authorize.kobo.com/inclusion " ,
" authorproduct_recommendations " : " https://storeapi.kobo.com/v1/products/books/authors/recommendations " ,
" autocomplete " : " https://storeapi.kobo.com/v1/products/autocomplete " ,
" blackstone_header " : { " key " : " x-amz-request-payer " , " value " : " requester " } ,
" book " : " https://storeapi.kobo.com/v1/products/books/ {ProductId} " ,
" book_detail_page " : " https://store.kobobooks.com/ {culture} /ebook/ {slug} " ,
" book_detail_page_rakuten " : " http://books.rakuten.co.jp/rk/ {crossrevisionid} " ,
" book_landing_page " : " https://store.kobobooks.com/ebooks " ,
" book_subscription " : " https://storeapi.kobo.com/v1/products/books/subscriptions " ,
" categories " : " https://storeapi.kobo.com/v1/categories " ,
" categories_page " : " https://store.kobobooks.com/ebooks/categories " ,
" category " : " https://storeapi.kobo.com/v1/categories/ {CategoryId} " ,
" category_featured_lists " : " https://storeapi.kobo.com/v1/categories/ {CategoryId} /featured " ,
" category_products " : " https://storeapi.kobo.com/v1/categories/ {CategoryId} /products " ,
" checkout_borrowed_book " : " https://storeapi.kobo.com/v1/library/borrow " ,
" configuration_data " : " https://storeapi.kobo.com/v1/configuration " ,
" content_access_book " : " https://storeapi.kobo.com/v1/products/books/ {ProductId} /access " ,
" customer_care_live_chat " : " https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO " ,
" daily_deal " : " https://storeapi.kobo.com/v1/products/dailydeal " ,
" deals " : " https://storeapi.kobo.com/v1/deals " ,
" delete_entitlement " : " https://storeapi.kobo.com/v1/library/ {Ids} " ,
" delete_tag " : " https://storeapi.kobo.com/v1/library/tags/ {TagId} " ,
" delete_tag_items " : " https://storeapi.kobo.com/v1/library/tags/ {TagId} /items/delete " ,
" device_auth " : " https://storeapi.kobo.com/v1/auth/device " ,
" device_refresh " : " https://storeapi.kobo.com/v1/auth/refresh " ,
" dictionary_host " : " https://kbdownload1-a.akamaihd.net " ,
" discovery_host " : " https://discovery.kobobooks.com " ,
" eula_page " : " https://www.kobo.com/termsofuse?style=onestore " ,
" exchange_auth " : " https://storeapi.kobo.com/v1/auth/exchange " ,
" external_book " : " https://storeapi.kobo.com/v1/products/books/external/ {Ids} " ,
" facebook_sso_page " : " https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://store.kobobooks.com/ " ,
" featured_list " : " https://storeapi.kobo.com/v1/products/featured/ {FeaturedListId} " ,
" featured_lists " : " https://storeapi.kobo.com/v1/products/featured " ,
" free_books_page " : {
" EN " : " https://www.kobo.com/ {region} / {language} /p/free-ebooks " ,
" FR " : " https://www.kobo.com/ {region} / {language} /p/livres-gratuits " ,
" IT " : " https://www.kobo.com/ {region} / {language} /p/libri-gratuiti " ,
" NL " : " https://www.kobo.com/ {region} / {language} /List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg " ,
" PT " : " https://www.kobo.com/ {region} / {language} /p/livros-gratis " ,
} ,
" fte_feedback " : " https://storeapi.kobo.com/v1/products/ftefeedback " ,
" get_tests_request " : " https://storeapi.kobo.com/v1/analytics/gettests " ,
" giftcard_epd_redeem_url " : " https://www.kobo.com/ {storefront} / {language} /redeem-ereader " ,
" giftcard_redeem_url " : " https://www.kobo.com/ {storefront} / {language} /redeem " ,
" help_page " : " http://www.kobo.com/help " ,
" image_host " : calibre_web_url ,
" image_url_quality_template " : calibre_web_url
+ " / {ImageId} / {Width} / {Height} / {Quality} / {IsGreyscale} /image.jpg " ,
" image_url_template " : calibre_web_url
+ " / {ImageId} / {Width} / {Height} /false/image.jpg " ,
" kobo_audiobooks_enabled " : " False " ,
" kobo_audiobooks_orange_deal_enabled " : " False " ,
" kobo_audiobooks_subscriptions_enabled " : " False " ,
" kobo_nativeborrow_enabled " : " True " ,
" kobo_onestorelibrary_enabled " : " False " ,
" kobo_redeem_enabled " : " True " ,
" kobo_shelfie_enabled " : " False " ,
" kobo_subscriptions_enabled " : " False " ,
" kobo_superpoints_enabled " : " False " ,
" kobo_wishlist_enabled " : " True " ,
" library_book " : " https://storeapi.kobo.com/v1/user/library/books/ {LibraryItemId} " ,
" library_items " : " https://storeapi.kobo.com/v1/user/library " ,
" library_metadata " : " https://storeapi.kobo.com/v1/library/ {Ids} /metadata " ,
" library_prices " : " https://storeapi.kobo.com/v1/user/library/previews/prices " ,
" library_stack " : " https://storeapi.kobo.com/v1/user/library/stacks/ {LibraryItemId} " ,
" library_sync " : " https://storeapi.kobo.com/v1/library/sync " ,
" love_dashboard_page " : " https://store.kobobooks.com/ {culture} /kobosuperpoints " ,
" love_points_redemption_page " : " https://store.kobobooks.com/ {culture} /KoboSuperPointsRedemption?productId= {ProductId} " ,
" magazine_landing_page " : " https://store.kobobooks.com/emagazines " ,
" notifications_registration_issue " : " https://storeapi.kobo.com/v1/notifications/registration " ,
" oauth_host " : " https://oauth.kobo.com " ,
" overdrive_account " : " https://auth.overdrive.com/account " ,
" overdrive_library " : " https:// {libraryKey} .auth.overdrive.com/library " ,
" overdrive_library_finder_host " : " https://libraryfinder.api.overdrive.com " ,
" overdrive_thunder_host " : " https://thunder.api.overdrive.com " ,
" password_retrieval_page " : " https://www.kobobooks.com/passwordretrieval.html " ,
" post_analytics_event " : " https://storeapi.kobo.com/v1/analytics/event " ,
" privacy_page " : " https://www.kobo.com/privacypolicy?style=onestore " ,
" product_nextread " : " https://storeapi.kobo.com/v1/products/ {ProductIds} /nextread " ,
" product_prices " : " https://storeapi.kobo.com/v1/products/ {ProductIds} /prices " ,
" product_recommendations " : " https://storeapi.kobo.com/v1/products/ {ProductId} /recommendations " ,
" product_reviews " : " https://storeapi.kobo.com/v1/products/ {ProductIds} /reviews " ,
" products " : " https://storeapi.kobo.com/v1/products " ,
" provider_external_sign_in_page " : " https://authorize.kobo.com/ExternalSignIn/ {providerName} ?returnUrl=http://store.kobobooks.com/ " ,
" purchase_buy " : " https://www.kobo.com/checkout/createpurchase/ " ,
" purchase_buy_templated " : " https://www.kobo.com/ {culture} /checkout/createpurchase/ {ProductId} " ,
" quickbuy_checkout " : " https://storeapi.kobo.com/v1/store/quickbuy/ {PurchaseId} /checkout " ,
" quickbuy_create " : " https://storeapi.kobo.com/v1/store/quickbuy/purchase " ,
" rating " : " https://storeapi.kobo.com/v1/products/ {ProductId} /rating/ {Rating} " ,
" reading_state " : " https://storeapi.kobo.com/v1/library/ {Ids} /state " ,
" redeem_interstitial_page " : " https://store.kobobooks.com " ,
" registration_page " : " https://authorize.kobo.com/signup?returnUrl=http://store.kobobooks.com/ " ,
" related_items " : " https://storeapi.kobo.com/v1/products/ {Id} /related " ,
" remaining_book_series " : " https://storeapi.kobo.com/v1/products/books/series/ {SeriesId} " ,
" rename_tag " : " https://storeapi.kobo.com/v1/library/tags/ {TagId} " ,
" review " : " https://storeapi.kobo.com/v1/products/reviews/ {ReviewId} " ,
" review_sentiment " : " https://storeapi.kobo.com/v1/products/reviews/ {ReviewId} /sentiment/ {Sentiment} " ,
" shelfie_recommendations " : " https://storeapi.kobo.com/v1/user/recommendations/shelfie " ,
" sign_in_page " : " https://authorize.kobo.com/signin?returnUrl=http://store.kobobooks.com/ " ,
" social_authorization_host " : " https://social.kobobooks.com:8443 " ,
" social_host " : " https://social.kobobooks.com " ,
" stacks_host_productId " : " https://store.kobobooks.com/collections/byproductid/ " ,
" store_home " : " www.kobo.com/ {region} / {language} " ,
" store_host " : " store.kobobooks.com " ,
" store_newreleases " : " https://store.kobobooks.com/ {culture} /List/new-releases/961XUjtsU0qxkFItWOutGA " ,
" store_search " : " https://store.kobobooks.com/ {culture} /Search?Query= {query} " ,
" store_top50 " : " https://store.kobobooks.com/ {culture} /ebooks/Top " ,
" tag_items " : " https://storeapi.kobo.com/v1/library/tags/ {TagId} /Items " ,
" tags " : " https://storeapi.kobo.com/v1/library/tags " ,
" taste_profile " : " https://storeapi.kobo.com/v1/products/tasteprofile " ,
" update_accessibility_to_preview " : " https://storeapi.kobo.com/v1/library/ {EntitlementIds} /preview " ,
" use_one_store " : " False " ,
" user_loyalty_benefits " : " https://storeapi.kobo.com/v1/user/loyalty/benefits " ,
" user_platform " : " https://storeapi.kobo.com/v1/user/platform " ,
" user_profile " : " https://storeapi.kobo.com/v1/user/profile " ,
" user_ratings " : " https://storeapi.kobo.com/v1/user/ratings " ,
" user_recommendations " : " https://storeapi.kobo.com/v1/user/recommendations " ,
" user_reviews " : " https://storeapi.kobo.com/v1/user/reviews " ,
" user_wishlist " : " https://storeapi.kobo.com/v1/user/wishlist " ,
" userguide_host " : " https://kbdownload1-a.akamaihd.net " ,
" wishlist_page " : " https://store.kobobooks.com/ {region} / {language} /account/wishlist " ,
}