Add initial support for Kobo device Sync endpoint.
- Supports /v1/library/sync call to get list of books - Supports /v1/library/metadata call to get metadata for a given book + Assumes books are stored on Backblaze for metadata call - Changes to helper.py so that we can return no cover instead of a blank image.pull/1138/head
parent
0c40e40dc3
commit
5357867103
@ -0,0 +1,582 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from flask import Blueprint, request, flash, redirect, url_for
|
||||||
|
from . import logger, ub, searched_ids, db, helper
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
from flask import make_response
|
||||||
|
from flask import jsonify
|
||||||
|
from flask import json
|
||||||
|
from flask import send_file
|
||||||
|
from time import gmtime, strftime
|
||||||
|
import uuid
|
||||||
|
from uuid import uuid4, uuid3
|
||||||
|
from collections import defaultdict
|
||||||
|
from b2sdk.account_info.in_memory import InMemoryAccountInfo
|
||||||
|
from b2sdk.api import B2Api
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, tzinfo, timedelta
|
||||||
|
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
|
import copy
|
||||||
|
import jsonschema
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
B2_SECRETS = os.path.join(_CONFIG_DIR, "b2_secrets.json")
|
||||||
|
|
||||||
|
kobo = Blueprint("kobo", __name__)
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
def b64encode(data):
|
||||||
|
return base64.b64encode(data)
|
||||||
|
|
||||||
|
|
||||||
|
def b64encode_json(json_data):
|
||||||
|
return b64encode(json.dumps(json_data))
|
||||||
|
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
sync_token_json = json.loads(
|
||||||
|
base64.b64decode(sync_token_header + "=" * (-len(sync_token_header) % 4))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
jsonschema.validate(sync_token_json, SyncToken.token_schema)
|
||||||
|
if sync_token_json["version"] < SyncToken.MIN_VERSION:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
data_json = sync_token_json["data"]
|
||||||
|
jsonschema.validate(sync_token_json, SyncToken.data_schema_v1)
|
||||||
|
except (jsonschema.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")
|
||||||
|
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)
|
||||||
|
.filter(func.datetime(db.Books.last_modified) > sync_token.books_last_modified)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for book in changed_entries:
|
||||||
|
entitlement = CreateEntitlement(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")
|
||||||
|
def get_metadata__v1(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:
|
||||||
|
log.info(u"Book %s not found in database", book_uuid)
|
||||||
|
return make_response("Book not found in database.", 404)
|
||||||
|
|
||||||
|
download_url = get_download_url_for_book(book)
|
||||||
|
if not download_url:
|
||||||
|
return make_response("Could not get a download url for book.", 500)
|
||||||
|
|
||||||
|
metadata = create_metadata(book)
|
||||||
|
metadata["DownloadUrls"] = [
|
||||||
|
{
|
||||||
|
"DrmType": "SignedNoDrm",
|
||||||
|
"Format": "KEPUB",
|
||||||
|
"Platform": "Android",
|
||||||
|
# TODO: Set the file size.
|
||||||
|
# "Size": file_info["contentLength"],
|
||||||
|
"Url": download_url,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return jsonify([metadata])
|
||||||
|
|
||||||
|
|
||||||
|
def get_download_url_for_book(book):
|
||||||
|
# TODO: Research what formats Kobo will support over the sync protocol.
|
||||||
|
# For now let's just assume all books are converted to KEPUB.
|
||||||
|
data = (
|
||||||
|
db.session.query(db.Data)
|
||||||
|
.filter(db.Data.book == book.id)
|
||||||
|
.filter(db.Data.format == "KEPUB")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
log.info(u"Book %s does have a kepub format", book_uuid)
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_name = data.name + ".kepub"
|
||||||
|
file_path = os.path.join(book.path, file_name)
|
||||||
|
|
||||||
|
if not os.path.isfile(B2_SECRETS):
|
||||||
|
log.error(u"b2 secret file not found")
|
||||||
|
return None
|
||||||
|
with open(B2_SECRETS, "r") as filedata:
|
||||||
|
secrets = json.load(filedata)
|
||||||
|
|
||||||
|
info = InMemoryAccountInfo()
|
||||||
|
b2_api = B2Api(info)
|
||||||
|
b2_api.authorize_account(
|
||||||
|
"production", secrets["application_key_id"], secrets["application_key"]
|
||||||
|
)
|
||||||
|
bucket = b2_api.get_bucket_by_name(secrets["bucket_name"])
|
||||||
|
if not bucket:
|
||||||
|
log.error(u"b2 bucket not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
download_url = b2_api.get_download_url_for_file_name(
|
||||||
|
secrets["bucket_name"], file_path
|
||||||
|
)
|
||||||
|
download_authorization = bucket.get_download_authorization(
|
||||||
|
file_path, valid_duration_in_seconds=600
|
||||||
|
)
|
||||||
|
return download_url + "?Authorization=" + download_authorization
|
||||||
|
|
||||||
|
|
||||||
|
def CreateBookEntitlement(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 CreateEntitlement(book):
|
||||||
|
return {
|
||||||
|
"BookEntitlement": CreateBookEntitlement(book),
|
||||||
|
"BookMetadata": create_metadata(book),
|
||||||
|
"ReadingState": reading_state(book),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def current_time():
|
||||||
|
return strftime("%Y-%m-%dT%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 create_metadata(book):
|
||||||
|
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": [
|
||||||
|
# Looks like we need to pass at least one url in the
|
||||||
|
# v1/library/sync call. The new entitlement is ignored
|
||||||
|
# otherwise.
|
||||||
|
# May want to experiment more with this.
|
||||||
|
{
|
||||||
|
"DrmType": "None",
|
||||||
|
"Format": "KEPUB",
|
||||||
|
"Platform": "Android",
|
||||||
|
"Size": 1024775,
|
||||||
|
"Url": "https://google.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"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):
|
||||||
|
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": uuid3(uuid.NAMESPACE_DNS, get_series(book).encode("utf-8")),
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def get_single_cc_value(book, custom_column_name):
|
||||||
|
custom_column_values = get_custom_column_values(book, custom_column_name)
|
||||||
|
if custom_column_values:
|
||||||
|
return custom_column_values[0].value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_custom_column_values(book, custom_column_name):
|
||||||
|
custom_column = (
|
||||||
|
db.session.query(db.Custom_Columns)
|
||||||
|
.filter(db.Custom_Columns.label == custom_column_name)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
cc_string = "custom_column_" + str(custom_column.id)
|
||||||
|
|
||||||
|
return getattr(book, cc_string)
|
||||||
|
|
||||||
|
|
||||||
|
def reading_state(book):
|
||||||
|
# TODO: Make the state custom columns configurable.
|
||||||
|
# Possibly use calibre-web User db instead of the Calibre metadata.db?
|
||||||
|
reading_state = {
|
||||||
|
"StatusInfo": {
|
||||||
|
"LastModified": get_single_cc_value(book, "lastreadtimestamp"),
|
||||||
|
"Status": get_single_cc_value(book, "reading_status"),
|
||||||
|
}
|
||||||
|
# TODO: CurrentBookmark, Location
|
||||||
|
}
|
||||||
|
return reading_state
|
||||||
|
|
||||||
|
|
||||||
|
# def get_shelves(book):
|
||||||
|
# shelves = get_custom_column_values(book, "myshelves")
|
||||||
|
# return shelves
|
||||||
|
|
||||||
|
|
||||||
|
@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("/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():
|
||||||
|
# Missing feature: Authentication :)
|
||||||
|
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=config.config_server_url)
|
||||||
|
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",
|
||||||
|
}
|
Loading…
Reference in New Issue