diff --git a/cps/db.py b/cps/db.py index 5765bf68..47f07bf0 100755 --- a/cps/db.py +++ b/cps/db.py @@ -33,7 +33,7 @@ from sqlalchemy.ext.declarative import declarative_base session = None cc_exceptions = ['datetime', 'comments', 'float', 'composite', 'series'] cc_classes = {} - +engine = None Base = declarative_base() @@ -288,7 +288,7 @@ class Books(Base): @property def atom_timestamp(self): - return (self.timestamp or '').replace(' ', 'T') + return (self.timestamp.strftime('%Y-%m-%dT%H:%M:%S+00:00') or '') class Custom_Columns(Base): __tablename__ = 'custom_columns' @@ -327,6 +327,7 @@ def update_title_sort(config, conn=None): def setup_db(config): dispose() + global engine if not config.config_calibre_dir: config.invalidate() @@ -428,3 +429,8 @@ def dispose(): if name.startswith("custom_column_") or name.startswith("books_custom_column_"): if table is not None: Base.metadata.remove(table) + +def reconnect_db(config): + session.close() + engine.dispose() + setup_db(config) diff --git a/cps/kobo.py b/cps/kobo.py index 49132f7e..67687008 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -23,18 +23,32 @@ import uuid from base64 import b64decode, b64encode from datetime import datetime from time import gmtime, strftime +try: + from urllib import unquote +except ImportError: + from urllib.parse import unquote from jsonschema import validate, exceptions -from flask import Blueprint, request, make_response, jsonify, json, current_app, url_for - +from flask import ( + Blueprint, + request, + make_response, + jsonify, + json, + current_app, + url_for, + redirect, +) from flask_login import login_required +from werkzeug.datastructures import Headers from sqlalchemy import func +import requests from . import config, logger, kobo_auth, db, helper from .web import download_required -#TODO: Test more formats :) . -KOBO_SUPPORTED_FORMATS = {"KEPUB"} +KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB", "EPUB3"]} +KOBO_STOREAPI_URL = "https://storeapi.kobo.com" kobo = Blueprint("kobo", __name__, url_prefix="/kobo/") kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo) @@ -55,6 +69,47 @@ def to_epoch_timestamp(datetime_object): return (datetime_object - datetime(1970, 1, 1)).total_seconds() +def get_store_url_for_current_request(): + # Programmatically modify the current url to point to the official Kobo store + base, sep, request_path_with_auth_token = request.full_path.rpartition("/kobo/") + auth_token, sep, request_path = request_path_with_auth_token.rstrip("?").partition( + "/" + ) + return KOBO_STOREAPI_URL + "/" + request_path + + +CONNECTION_SPECIFIC_HEADERS = [ + "connection", + "content-encoding", + "content-length", + "transfer-encoding", +] + + +def redirect_or_proxy_request(): + if request.method == "GET": + return redirect(get_store_url_for_current_request(), 307) + else: + # The Kobo device turns other request types into GET requests on redirects, so we instead proxy to the Kobo store ourselves. + outgoing_headers = Headers(request.headers) + outgoing_headers.remove("Host") + store_response = requests.request( + method=request.method, + url=get_store_url_for_current_request(), + headers=outgoing_headers, + data=request.get_data(), + allow_redirects=False, + ) + + response_headers = store_response.headers + for header_key in CONNECTION_SPECIFIC_HEADERS: + response_headers.pop(header_key, default=None) + + return make_response( + store_response.content, store_response.status_code, response_headers.items() + ) + + 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. @@ -138,6 +193,14 @@ class SyncToken: books_last_modified=books_last_modified, ) + def set_kobo_store_header(self, store_headers): + store_headers.set(SyncToken.SYNC_TOKEN_HEADER, self.raw_kobo_store_token) + + def merge_from_store_response(self, store_response): + self.raw_kobo_store_token = store_response.headers.get( + SyncToken.SYNC_TOKEN_HEADER, "" + ) + def to_headers(self, headers): headers[SyncToken.SYNC_TOKEN_HEADER] = self.build_sync_token() @@ -167,6 +230,10 @@ def HandleSyncRequest(): new_books_last_created = sync_token.books_last_created entitlements = [] + # We reload the book database so that the user get's a fresh view of the library + # in case of external changes (e.g: adding a book through Calibre). + db.reconnect_db(config) + # 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. @@ -174,7 +241,7 @@ def HandleSyncRequest(): 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)) + .filter(db.Data.format.in_(KOBO_FORMATS)) .all() ) for book in changed_entries: @@ -198,13 +265,40 @@ def HandleSyncRequest(): # 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). + return generate_sync_response(request, sync_token, entitlements) + + +def generate_sync_response(request, sync_token, entitlements): + # We first merge in sync results from the official Kobo store. + outgoing_headers = Headers(request.headers) + outgoing_headers.remove("Host") + sync_token.set_kobo_store_header(outgoing_headers) + store_response = requests.request( + method=request.method, + url=get_store_url_for_current_request(), + headers=outgoing_headers, + data=request.get_data(), + ) + + store_entitlements = store_response.json() + entitlements += store_entitlements + sync_token.merge_from_store_response(store_response) response = make_response(jsonify(entitlements)) sync_token.to_headers(response.headers) - response.headers["x-kobo-sync-mode"] = "delta" - response.headers["x-kobo-apitoken"] = "e30=" + try: + # These headers could probably use some more investigation. + response.headers["x-kobo-sync"] = store_response.headers["x-kobo-sync"] + response.headers["x-kobo-sync-mode"] = store_response.headers[ + "x-kobo-sync-mode" + ] + response.headers["x-kobo-recent-reads"] = store_response.headers[ + "x-kobo-recent-reads" + ] + except KeyError: + pass + return response @@ -216,7 +310,7 @@ def HandleMetadataRequest(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) + return redirect_or_proxy_request() metadata = get_metadata(book) return jsonify([metadata]) @@ -283,14 +377,17 @@ def get_metadata(book): download_urls = [] for book_data in book.data: - if book_data.format in KOBO_SUPPORTED_FORMATS: + if book_data.format not in KOBO_FORMATS: + continue + for kobo_format in KOBO_FORMATS[book_data.format]: download_urls.append( { - "Format": book_data.format, + "Format": kobo_format, "Size": book_data.uncompressed_size, "Url": get_download_url_for_book(book, book_data.format), + # The Kobo forma accepts platforms: (Generic, Android) + "Platform": "Generic", # "DrmType": "None", # Not required - "Platform": "Android", # Required field. } ) @@ -313,7 +410,7 @@ def get_metadata(book): "IsSocialEnabled": True, "Language": "en", "PhoneticPronunciations": {}, - "PublicationDate": "2019-02-03T00:25:03.0000000Z", # current_time(), + "PublicationDate": book.pubdate, "Publisher": {"Imprint": "", "Name": get_publisher(book),}, "RevisionId": book_uuid, "Title": book.title, @@ -349,14 +446,15 @@ def reading_state(book): @kobo.route( - "//////image.jpg" + "//image.jpg" ) -def HandleCoverImageRequest(book_uuid, horizontal, vertical, jpeg_quality, monochrome): +@login_required +def HandleCoverImageRequest(book_uuid): book_cover = helper.get_book_cover_with_uuid( book_uuid, use_generic_cover_on_failure=False ) if not book_cover: - return make_response() + return redirect(get_store_url_for_current_request(), 307) return book_cover @@ -365,173 +463,46 @@ 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/") -@kobo.route("/v1/user/recommendations") -@kobo.route("/v1/products/") -@kobo.route("/v1/products//nextread") -@kobo.route("/v1/products/featured/") -@kobo.route("/v1/products/featured/") -@kobo.route("/v1/library/", methods=["DELETE", "GET"]) # TODO: implement -def HandleDummyRequest(dummy=None): - return make_response(jsonify({})) +# TODO: Implement the following routes +@kobo.route("/v1/library/", methods=["DELETE", "GET"]) +@kobo.route("/v1/library//state", methods=["PUT"]) +@kobo.route("/v1/library/tags", methods=["POST"]) +@kobo.route("/v1/library/tags/", methods=["POST"]) +@kobo.route("/v1/library/tags/", methods=["DELETE"]) +def HandleUnimplementedRequest(book_uuid=None, shelf_name=None, tag_id=None): + return redirect_or_proxy_request() -@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.app_errorhandler(404) +def handle_404(err): + # This handler acts as a catch-all for endpoints that we don't have an interest in + # implementing (e.g: v1/analytics/gettests, v1/user/recommendations, etc) + return redirect_or_proxy_request() @kobo.route("/v1/initialization") +@login_required def HandleInitRequest(): - resources = NATIVE_KOBO_RESOURCES( - calibre_web_url=url_for("web.index", _external=True).strip("/") + outgoing_headers = Headers(request.headers) + outgoing_headers.remove("Host") + store_response = requests.request( + method=request.method, + url=get_store_url_for_current_request(), + headers=outgoing_headers, + data=request.get_data(), ) - response = make_response(jsonify({"Resources": resources})) - response.headers["x-kobo-apitoken"] = "e30=" - return response + store_response_json = store_response.json() + if "Resources" in store_response_json: + kobo_resources = store_response_json["Resources"] -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", - } + calibre_web_url = url_for("web.index", _external=True).strip("/") + kobo_resources["image_host"] = calibre_web_url + kobo_resources["image_url_quality_template"] = unquote(url_for("kobo.HandleCoverImageRequest", _external=True, + auth_token = kobo_auth.get_auth_token(), + book_uuid="{ImageId}")) + kobo_resources["image_url_template"] = unquote(url_for("kobo.HandleCoverImageRequest", _external=True, + auth_token = kobo_auth.get_auth_token(), + book_uuid="{ImageId}")) + + return make_response(store_response_json, store_response.status_code) diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py index 42fac356..2077ce75 100644 --- a/cps/kobo_auth.py +++ b/cps/kobo_auth.py @@ -81,10 +81,17 @@ def disable_failed_auth_redirect_for_blueprint(bp): lm.blueprint_login_views[bp.name] = None +def get_auth_token(): + if "auth_token" in g: + return g.get("auth_token") + else: + return None + + @lm.request_loader def load_user_from_kobo_request(request): - if "auth_token" in g: - auth_token = g.get("auth_token") + auth_token = get_auth_token() + if auth_token is not None: user = ( ub.session.query(ub.User) .join(ub.RemoteAuthToken) diff --git a/cps/opds.py b/cps/opds.py index f5cc4673..fcca305f 100644 --- a/cps/opds.py +++ b/cps/opds.py @@ -276,7 +276,7 @@ def feed_languages(book_id): isoLanguages.get(part3=entry.languages[index].lang_code).name)''' return render_xml_template('feed.xml', entries=entries, pagination=pagination) -@opds.route("/opds/shelfindex/", defaults={'public': 0}) +@opds.route("/opds/shelfindex", defaults={'public': 0}) @opds.route("/opds/shelfindex/") @requires_basic_auth_if_no_ano def feed_shelfindex(public): @@ -378,14 +378,14 @@ def render_xml_template(*args, **kwargs): def feed_get_cover(book_id): return get_book_cover(book_id) -@opds.route("/opds/readbooks/") +@opds.route("/opds/readbooks") @requires_basic_auth_if_no_ano def feed_read_books(): off = request.args.get("offset") or 0 return render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True) -@opds.route("/opds/unreadbooks/") +@opds.route("/opds/unreadbooks") @requires_basic_auth_if_no_ano def feed_unread_books(): off = request.args.get("offset") or 0 diff --git a/cps/web.py b/cps/web.py index 90bc1c8a..b84f6dd9 100644 --- a/cps/web.py +++ b/cps/web.py @@ -43,7 +43,7 @@ from werkzeug.exceptions import default_exceptions from werkzeug.datastructures import Headers from werkzeug.security import generate_password_hash, check_password_hash -from . import constants, logger, isoLanguages, services, worker +from . import constants, config, logger, isoLanguages, services, worker from . import searched_ids, lm, babel, db, ub, config, get_locale, app from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download from .helper import common_filters, get_search_results, fill_indexpage, speaking_language, check_valid_domain, \ @@ -93,12 +93,12 @@ def error_http(error): def internal_error(error): - __, __, tb = sys.exc_info() + # __, __, tb = sys.exc_info() return render_template('http_error.html', error_code="Internal Server Error", error_name=str(error), issue=True, - error_stack=traceback.format_tb(tb), + error_stack=traceback.format_exc().split("\n"), instance=config.config_calibre_web_title ), 500 @@ -790,9 +790,7 @@ def get_tasks_status(): @app.route("/reconnect") def reconnect(): - db.session.close() - db.engine.dispose() - db.setup_db() + db.reconnect_db(config) return json.dumps({}) @web.route("/search", methods=["GET"]) @@ -961,7 +959,7 @@ def advanced_search(): series=series, title=_(u"search"), cc=cc, page="advsearch") -def render_read_books(page, are_read, as_xml=False, order=None): +def render_read_books(page, are_read, as_xml=False, order=None, *args, **kwargs): order = order or [] if not config.config_read_column: readBooks = ub.session.query(ub.ReadBook).filter(ub.ReadBook.user_id == int(current_user.id))\ @@ -984,7 +982,8 @@ def render_read_books(page, are_read, as_xml=False, order=None): entries, random, pagination = fill_indexpage(page, db.Books, db_filter, order) if as_xml: - xml = render_title_template('feed.xml', entries=entries, pagination=pagination) + currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00") + xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, *args, **kwargs) response = make_response(xml) response.headers["Content-Type"] = "application/xml; charset=utf-8" return response