mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-08 02:34:06 -04:00
CS: Implement /opds
Structure remains the same as the old server, except that there is support for multiple libraries
This commit is contained in:
parent
27527d4299
commit
2ef515b6c0
@ -1,4 +1,4 @@
|
|||||||
Remove tweak to control interface to listen on
|
Remove all *content_server_* and server_listen_on tweaks
|
||||||
|
|
||||||
Rewrite server integration with nginx/apache section
|
Rewrite server integration with nginx/apache section
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ from calibre.ebooks.metadata.book.json_codec import JsonCodec
|
|||||||
from calibre.srv.errors import HTTPNotFound
|
from calibre.srv.errors import HTTPNotFound
|
||||||
from calibre.srv.routes import endpoint, json
|
from calibre.srv.routes import endpoint, json
|
||||||
from calibre.srv.content import get as get_content, icon as get_icon
|
from calibre.srv.content import get as get_content, icon as get_icon
|
||||||
from calibre.srv.utils import http_date, custom_fields_to_display, encode_name, decode_name
|
from calibre.srv.utils import http_date, custom_fields_to_display, encode_name, decode_name, get_db
|
||||||
from calibre.utils.config import prefs, tweaks
|
from calibre.utils.config import prefs, tweaks
|
||||||
from calibre.utils.date import isoformat, timestampfromdt
|
from calibre.utils.date import isoformat, timestampfromdt
|
||||||
from calibre.utils.icu import numeric_sort_key as sort_key
|
from calibre.utils.icu import numeric_sort_key as sort_key
|
||||||
@ -39,12 +39,6 @@ def get_pagination(query, num=100, offset=0):
|
|||||||
raise HTTPNotFound("Invalid offset")
|
raise HTTPNotFound("Invalid offset")
|
||||||
return num, offset
|
return num, offset
|
||||||
|
|
||||||
def get_db(ctx, library_id):
|
|
||||||
db = ctx.get_library(library_id)
|
|
||||||
if db is None:
|
|
||||||
raise HTTPNotFound('Library %r not found' % library_id)
|
|
||||||
return db
|
|
||||||
|
|
||||||
def category_icon(category, meta): # {{{
|
def category_icon(category, meta): # {{{
|
||||||
if category in category_icon_map:
|
if category in category_icon_map:
|
||||||
icon = category_icon_map[category]
|
icon = category_icon_map[category]
|
||||||
|
@ -13,10 +13,11 @@ from calibre import prepare_string_for_xml, as_unicode
|
|||||||
from calibre.constants import config_dir
|
from calibre.constants import config_dir
|
||||||
from calibre.customize.ui import available_input_formats
|
from calibre.customize.ui import available_input_formats
|
||||||
from calibre.db.view import sanitize_sort_field_name
|
from calibre.db.view import sanitize_sort_field_name
|
||||||
from calibre.srv.ajax import get_db, search_result
|
from calibre.srv.ajax import search_result
|
||||||
from calibre.srv.errors import HTTPNotFound, HTTPBadRequest
|
from calibre.srv.errors import HTTPNotFound, HTTPBadRequest
|
||||||
from calibre.srv.metadata import book_as_json, categories_as_json, icon_map
|
from calibre.srv.metadata import book_as_json, categories_as_json, icon_map
|
||||||
from calibre.srv.routes import endpoint, json
|
from calibre.srv.routes import endpoint, json
|
||||||
|
from calibre.srv.utils import get_library_data
|
||||||
from calibre.utils.config import prefs, tweaks
|
from calibre.utils.config import prefs, tweaks
|
||||||
from calibre.utils.icu import sort_key
|
from calibre.utils.icu import sort_key
|
||||||
from calibre.utils.search_query_parser import ParseException
|
from calibre.utils.search_query_parser import ParseException
|
||||||
@ -61,14 +62,6 @@ def index(ctx, rd):
|
|||||||
DEFAULT_LIBRARY=json_dumps(default_library)
|
DEFAULT_LIBRARY=json_dumps(default_library)
|
||||||
))
|
))
|
||||||
|
|
||||||
def get_library_data(ctx, query):
|
|
||||||
library_id = query.get('library_id')
|
|
||||||
library_map, default_library = ctx.library_map
|
|
||||||
if library_id not in library_map:
|
|
||||||
library_id = default_library
|
|
||||||
db = get_db(ctx, library_id)
|
|
||||||
return db, library_id, library_map, default_library
|
|
||||||
|
|
||||||
def get_basic_query_data(ctx, query):
|
def get_basic_query_data(ctx, query):
|
||||||
db, library_id, library_map, default_library = get_library_data(ctx, query)
|
db, library_id, library_map, default_library = get_library_data(ctx, query)
|
||||||
skeys = db.field_metadata.sortable_field_keys()
|
skeys = db.field_metadata.sortable_field_keys()
|
||||||
|
@ -173,7 +173,7 @@ class Handler(object):
|
|||||||
prefer_basic_auth = {'auto':has_ssl, 'basic':True}.get(opts.auth_mode, 'digest')
|
prefer_basic_auth = {'auto':has_ssl, 'basic':True}.get(opts.auth_mode, 'digest')
|
||||||
self.auth_controller = AuthController(user_credentials=ctx.user_manager, prefer_basic_auth=prefer_basic_auth)
|
self.auth_controller = AuthController(user_credentials=ctx.user_manager, prefer_basic_auth=prefer_basic_auth)
|
||||||
self.router = Router(ctx=ctx, url_prefix=opts.url_prefix, auth_controller=self.auth_controller)
|
self.router = Router(ctx=ctx, url_prefix=opts.url_prefix, auth_controller=self.auth_controller)
|
||||||
for module in ('content', 'ajax', 'code', 'legacy'):
|
for module in ('content', 'ajax', 'code', 'legacy', 'opds'):
|
||||||
module = import_module('calibre.srv.' + module)
|
module = import_module('calibre.srv.' + module)
|
||||||
self.router.load_routes(vars(module).itervalues())
|
self.router.load_routes(vars(module).itervalues())
|
||||||
self.router.finalize()
|
self.router.finalize()
|
||||||
|
@ -11,9 +11,12 @@ from calibre.srv.routes import endpoint
|
|||||||
|
|
||||||
@endpoint('/browse/{+rest=""}')
|
@endpoint('/browse/{+rest=""}')
|
||||||
def browse(ctx, rd, rest):
|
def browse(ctx, rd, rest):
|
||||||
raise HTTPRedirect(ctx.url_for('') or '/')
|
raise HTTPRedirect(ctx.url_for(None))
|
||||||
|
|
||||||
@endpoint('/mobile/{+rest=""}')
|
@endpoint('/mobile/{+rest=""}')
|
||||||
def mobile(ctx, rd, rest):
|
def mobile(ctx, rd, rest):
|
||||||
raise HTTPRedirect(ctx.url_for('') or '/')
|
raise HTTPRedirect(ctx.url_for(None))
|
||||||
|
|
||||||
|
@endpoint('/stanza/{+rest=""}')
|
||||||
|
def stanza(ctx, rd, rest):
|
||||||
|
raise HTTPRedirect(ctx.url_for('/opds'))
|
||||||
|
592
src/calibre/srv/opds.py
Normal file
592
src/calibre/srv/opds.py
Normal file
@ -0,0 +1,592 @@
|
|||||||
|
#!/usr/bin/env python2
|
||||||
|
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
|
||||||
|
__docformat__ = 'restructuredtext en'
|
||||||
|
|
||||||
|
import hashlib, binascii
|
||||||
|
from functools import partial
|
||||||
|
from itertools import repeat
|
||||||
|
from collections import OrderedDict, namedtuple
|
||||||
|
from urllib import urlencode
|
||||||
|
|
||||||
|
from lxml import etree, html
|
||||||
|
from lxml.builder import ElementMaker
|
||||||
|
|
||||||
|
from calibre.constants import __appname__
|
||||||
|
from calibre.ebooks.metadata import fmt_sidx, authors_to_string
|
||||||
|
from calibre.library.comments import comments_to_html
|
||||||
|
from calibre import guess_type, prepare_string_for_xml as xml
|
||||||
|
from calibre.utils.config import tweaks
|
||||||
|
from calibre.utils.icu import sort_key
|
||||||
|
from calibre.utils.date import as_utc, timestampfromdt
|
||||||
|
|
||||||
|
from calibre.srv.errors import HTTPNotFound
|
||||||
|
from calibre.srv.routes import endpoint
|
||||||
|
from calibre.srv.utils import get_library_data, http_date, Offsets
|
||||||
|
|
||||||
|
def hexlify(x):
|
||||||
|
if isinstance(x, unicode):
|
||||||
|
x = x.encode('utf-8')
|
||||||
|
return binascii.hexlify(x)
|
||||||
|
|
||||||
|
def unhexlify(x):
|
||||||
|
return binascii.unhexlify(x).decode('utf-8')
|
||||||
|
|
||||||
|
def atom(ctx, rd, endpoint, output):
|
||||||
|
rd.outheaders.set('Content-Type', 'application/atom+xml; charset=UTF-8', replace_all=True)
|
||||||
|
if isinstance(output, bytes):
|
||||||
|
ans = output # Assume output is already UTF-8 XML
|
||||||
|
elif isinstance(output, type('')):
|
||||||
|
ans = output.encode('utf-8')
|
||||||
|
else:
|
||||||
|
from lxml import etree
|
||||||
|
ans = etree.tostring(output, encoding='utf-8', xml_declaration=True, pretty_print=True)
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def format_tag_string(tags, sep, no_tag_count=False, joinval=', '):
|
||||||
|
if tags:
|
||||||
|
tlist = tags if sep is None else [t.strip() for t in tags.split(sep)]
|
||||||
|
else:
|
||||||
|
tlist = []
|
||||||
|
tlist.sort(key=sort_key)
|
||||||
|
if no_tag_count:
|
||||||
|
return joinval.join(tlist) if tlist else ''
|
||||||
|
else:
|
||||||
|
return u'%s:&:%s'%(tweaks['max_content_server_tags_shown'],
|
||||||
|
joinval.join(tlist)) if tlist else ''
|
||||||
|
|
||||||
|
# Vocabulary for building OPDS feeds {{{
|
||||||
|
E = ElementMaker(namespace='http://www.w3.org/2005/Atom',
|
||||||
|
nsmap={
|
||||||
|
None : 'http://www.w3.org/2005/Atom',
|
||||||
|
'dc' : 'http://purl.org/dc/terms/',
|
||||||
|
'opds' : 'http://opds-spec.org/2010/catalog',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
FEED = E.feed
|
||||||
|
TITLE = E.title
|
||||||
|
ID = E.id
|
||||||
|
ICON = E.icon
|
||||||
|
|
||||||
|
def UPDATED(dt, *args, **kwargs):
|
||||||
|
return E.updated(as_utc(dt).strftime('%Y-%m-%dT%H:%M:%S+00:00'), *args, **kwargs)
|
||||||
|
|
||||||
|
LINK = partial(E.link, type='application/atom+xml')
|
||||||
|
NAVLINK = partial(E.link,
|
||||||
|
type='application/atom+xml;type=feed;profile=opds-catalog')
|
||||||
|
|
||||||
|
def SEARCH_LINK(url_for, *args, **kwargs):
|
||||||
|
kwargs['rel'] = 'search'
|
||||||
|
kwargs['title'] = 'Search'
|
||||||
|
kwargs['href'] = url_for('/opds/search', query='XXX').replace('XXX', '{searchTerms}')
|
||||||
|
return LINK(*args, **kwargs)
|
||||||
|
|
||||||
|
def AUTHOR(name, uri=None):
|
||||||
|
args = [E.name(name)]
|
||||||
|
if uri is not None:
|
||||||
|
args.append(E.uri(uri))
|
||||||
|
return E.author(*args)
|
||||||
|
|
||||||
|
SUBTITLE = E.subtitle
|
||||||
|
|
||||||
|
def NAVCATALOG_ENTRY(url_for, updated, title, description, query):
|
||||||
|
href = url_for('/opds/navcatalog', which=hexlify(query))
|
||||||
|
id_ = 'calibre-navcatalog:'+str(hashlib.sha1(href).hexdigest())
|
||||||
|
return E.entry(
|
||||||
|
TITLE(title),
|
||||||
|
ID(id_),
|
||||||
|
UPDATED(updated),
|
||||||
|
E.content(description, type='text'),
|
||||||
|
NAVLINK(href=href)
|
||||||
|
)
|
||||||
|
|
||||||
|
START_LINK = partial(NAVLINK, rel='start')
|
||||||
|
UP_LINK = partial(NAVLINK, rel='up')
|
||||||
|
FIRST_LINK = partial(NAVLINK, rel='first')
|
||||||
|
LAST_LINK = partial(NAVLINK, rel='last')
|
||||||
|
NEXT_LINK = partial(NAVLINK, rel='next', title='Next')
|
||||||
|
PREVIOUS_LINK = partial(NAVLINK, rel='previous')
|
||||||
|
|
||||||
|
def html_to_lxml(raw):
|
||||||
|
raw = u'<div>%s</div>'%raw
|
||||||
|
root = html.fragment_fromstring(raw)
|
||||||
|
root.set('xmlns', "http://www.w3.org/1999/xhtml")
|
||||||
|
raw = etree.tostring(root, encoding=None)
|
||||||
|
try:
|
||||||
|
return etree.fromstring(raw)
|
||||||
|
except:
|
||||||
|
for x in root.iterdescendants():
|
||||||
|
remove = []
|
||||||
|
for attr in x.attrib:
|
||||||
|
if ':' in attr:
|
||||||
|
remove.append(attr)
|
||||||
|
for a in remove:
|
||||||
|
del x.attrib[a]
|
||||||
|
raw = etree.tostring(root, encoding=None)
|
||||||
|
try:
|
||||||
|
return etree.fromstring(raw)
|
||||||
|
except:
|
||||||
|
from calibre.ebooks.oeb.parse_utils import _html4_parse
|
||||||
|
return _html4_parse(raw)
|
||||||
|
|
||||||
|
def CATALOG_ENTRY(item, item_kind, request_context, updated, catalog_name,
|
||||||
|
ignore_count=False, add_kind=False):
|
||||||
|
id_ = 'calibre:category:'+item.name
|
||||||
|
iid = 'N' + item.name
|
||||||
|
if item.id is not None:
|
||||||
|
iid = 'I' + str(item.id)
|
||||||
|
iid += ':'+item_kind
|
||||||
|
href = request_context.url_for('/opds/category', category=hexlify(catalog_name), which=hexlify(iid))
|
||||||
|
link = NAVLINK(href=href)
|
||||||
|
count = (_('%d books') if item.count > 1 else _('%d book'))%item.count
|
||||||
|
if ignore_count:
|
||||||
|
count = ''
|
||||||
|
if item.use_sort_as_name:
|
||||||
|
name = item.sort
|
||||||
|
else:
|
||||||
|
name = item.name
|
||||||
|
return E.entry(
|
||||||
|
TITLE(name + ('' if not add_kind else ' (%s)'%item_kind)),
|
||||||
|
ID(id_),
|
||||||
|
UPDATED(updated),
|
||||||
|
E.content(count, type='text'),
|
||||||
|
link
|
||||||
|
)
|
||||||
|
|
||||||
|
def CATALOG_GROUP_ENTRY(item, category, request_context, updated):
|
||||||
|
id_ = 'calibre:category-group:'+category+':'+item.text
|
||||||
|
iid = item.text
|
||||||
|
link = NAVLINK(href=request_context.url_for('/opds/categorygroup', category=hexlify(category), which=hexlify(iid)))
|
||||||
|
return E.entry(
|
||||||
|
TITLE(item.text),
|
||||||
|
ID(id_),
|
||||||
|
UPDATED(updated),
|
||||||
|
E.content(_('%d items')%item.count, type='text'),
|
||||||
|
link
|
||||||
|
)
|
||||||
|
|
||||||
|
def ACQUISITION_ENTRY(book_id, updated, request_context):
|
||||||
|
field_metadata = request_context.db.field_metadata
|
||||||
|
mi = request_context.db.get_metadata(book_id)
|
||||||
|
extra = []
|
||||||
|
if mi.rating > 0:
|
||||||
|
rating = u''.join(repeat(u'\u2605', int(mi.rating/2.)))
|
||||||
|
extra.append(_('RATING: %s<br />')%rating)
|
||||||
|
if mi.tags:
|
||||||
|
extra.append(_('TAGS: %s<br />')%xml(format_tag_string(mi.tags, None, no_tag_count=True)))
|
||||||
|
if mi.series:
|
||||||
|
extra.append(_('SERIES: %(series)s [%(sidx)s]<br />')%
|
||||||
|
dict(series=xml(mi.series),
|
||||||
|
sidx=fmt_sidx(float(mi.series_index))))
|
||||||
|
for key in field_metadata.ignorable_field_keys():
|
||||||
|
name, val = mi.format_field(key)
|
||||||
|
if val:
|
||||||
|
fm = field_metadata[key]
|
||||||
|
datatype = fm['datatype']
|
||||||
|
if datatype == 'text' and fm['is_multiple']:
|
||||||
|
extra.append('%s: %s<br />'%
|
||||||
|
(xml(name),
|
||||||
|
xml(format_tag_string(val,
|
||||||
|
fm['is_multiple']['ui_to_list'],
|
||||||
|
no_tag_count=True,
|
||||||
|
joinval=fm['is_multiple']['list_to_ui']))))
|
||||||
|
elif datatype == 'comments' or (fm['datatype'] == 'composite' and
|
||||||
|
fm['display'].get('contains_html', False)):
|
||||||
|
extra.append('%s: %s<br />'%(xml(name), comments_to_html(unicode(val))))
|
||||||
|
else:
|
||||||
|
extra.append('%s: %s<br />'%(xml(name), xml(unicode(val))))
|
||||||
|
if mi.comments:
|
||||||
|
comments = comments_to_html(mi.comments)
|
||||||
|
extra.append(comments)
|
||||||
|
if extra:
|
||||||
|
extra = html_to_lxml('\n'.join(extra))
|
||||||
|
ans = E.entry(TITLE(mi.title), E.author(E.name(authors_to_string(mi.authors))), ID('urn:uuid:' + mi.uuid), UPDATED(updated))
|
||||||
|
if len(extra):
|
||||||
|
ans.append(E.content(extra, type='xhtml'))
|
||||||
|
get = partial(request_context.ctx.url_for, '/get', book_id=book_id, library_id=request_context.library_id)
|
||||||
|
if mi.formats:
|
||||||
|
for fmt in mi.formats:
|
||||||
|
fmt = fmt.lower()
|
||||||
|
mt = guess_type('a.'+fmt)[0]
|
||||||
|
if mt:
|
||||||
|
ans.append(E.link(type=mt, href=get(what=fmt), rel="http://opds-spec.org/acquisition"))
|
||||||
|
ans.append(E.link(type='image/jpeg', href=get(what='cover'), rel="http://opds-spec.org/cover"))
|
||||||
|
ans.append(E.link(type='image/jpeg', href=get(what='thumb'), rel="http://opds-spec.org/thumbnail"))
|
||||||
|
|
||||||
|
return ans
|
||||||
|
|
||||||
|
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
default_feed_title = __appname__ + ' ' + _('Library')
|
||||||
|
|
||||||
|
class Feed(object): # {{{
|
||||||
|
|
||||||
|
def __init__(self, id_, updated, request_context, subtitle=None,
|
||||||
|
title=None,
|
||||||
|
up_link=None, first_link=None, last_link=None,
|
||||||
|
next_link=None, previous_link=None):
|
||||||
|
self.base_href = request_context.url_for('/opds')
|
||||||
|
|
||||||
|
self.root = \
|
||||||
|
FEED(
|
||||||
|
TITLE(title or default_feed_title),
|
||||||
|
AUTHOR(__appname__, uri='http://calibre-ebook.com'),
|
||||||
|
ID(id_),
|
||||||
|
ICON(request_context.ctx.url_for('/favicon.png')),
|
||||||
|
UPDATED(updated),
|
||||||
|
SEARCH_LINK(request_context.url_for),
|
||||||
|
START_LINK(href=request_context.url_for('/opds'))
|
||||||
|
)
|
||||||
|
if up_link:
|
||||||
|
self.root.append(UP_LINK(href=up_link))
|
||||||
|
if first_link:
|
||||||
|
self.root.append(FIRST_LINK(href=first_link))
|
||||||
|
if last_link:
|
||||||
|
self.root.append(LAST_LINK(href=last_link))
|
||||||
|
if next_link:
|
||||||
|
self.root.append(NEXT_LINK(href=next_link))
|
||||||
|
if previous_link:
|
||||||
|
self.root.append(PREVIOUS_LINK(href=previous_link))
|
||||||
|
if subtitle:
|
||||||
|
self.root.insert(1, SUBTITLE(subtitle))
|
||||||
|
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
class TopLevel(Feed): # {{{
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
updated, # datetime object in UTC
|
||||||
|
categories,
|
||||||
|
request_context,
|
||||||
|
id_='urn:calibre:main',
|
||||||
|
subtitle=_('Books in your library')
|
||||||
|
):
|
||||||
|
Feed.__init__(self, id_, updated, request_context, subtitle=subtitle)
|
||||||
|
|
||||||
|
subc = partial(NAVCATALOG_ENTRY, request_context.url_for, updated)
|
||||||
|
subcatalogs = [subc(_('By ')+title,
|
||||||
|
_('Books sorted by ') + desc, q) for title, desc, q in
|
||||||
|
categories]
|
||||||
|
for x in subcatalogs:
|
||||||
|
self.root.append(x)
|
||||||
|
for library_id, library_name in request_context.library_map.iteritems():
|
||||||
|
id_ = 'calibre-library:' + library_id
|
||||||
|
self.root.append(E.entry(
|
||||||
|
TITLE(_('Library:') + ' ' + library_name),
|
||||||
|
ID(id_),
|
||||||
|
UPDATED(updated),
|
||||||
|
E.content(_('Change calibre library to:') + ' ' + library_name, type='text'),
|
||||||
|
NAVLINK(href=request_context.url_for('/opds', library_id=library_id))
|
||||||
|
))
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
class NavFeed(Feed):
|
||||||
|
|
||||||
|
def __init__(self, id_, updated, request_context, offsets, page_url, up_url, title=None):
|
||||||
|
kwargs = {'up_link': up_url}
|
||||||
|
kwargs['first_link'] = page_url
|
||||||
|
kwargs['last_link'] = page_url+'?offset=%d'%offsets.last_offset
|
||||||
|
if offsets.offset > 0:
|
||||||
|
kwargs['previous_link'] = \
|
||||||
|
page_url+'?offset=%d'%offsets.previous_offset
|
||||||
|
if offsets.next_offset > -1:
|
||||||
|
kwargs['next_link'] = \
|
||||||
|
page_url+'?offset=%d'%offsets.next_offset
|
||||||
|
if title:
|
||||||
|
kwargs['title'] = title
|
||||||
|
Feed.__init__(self, id_, updated, request_context, **kwargs)
|
||||||
|
|
||||||
|
class AcquisitionFeed(NavFeed):
|
||||||
|
|
||||||
|
def __init__(self, id_, updated, request_context, items, offsets, page_url, up_url, title=None):
|
||||||
|
NavFeed.__init__(self, id_, updated, request_context, offsets, page_url, up_url, title=title)
|
||||||
|
for book_id in items:
|
||||||
|
self.root.append(ACQUISITION_ENTRY(book_id, updated, request_context))
|
||||||
|
|
||||||
|
class CategoryFeed(NavFeed):
|
||||||
|
|
||||||
|
def __init__(self, items, which, id_, updated, request_context, offsets, page_url, up_url, title=None):
|
||||||
|
NavFeed.__init__(self, id_, updated, request_context, offsets, page_url, up_url, title=title)
|
||||||
|
ignore_count = False
|
||||||
|
if which == 'search':
|
||||||
|
ignore_count = True
|
||||||
|
for item in items:
|
||||||
|
self.root.append(CATALOG_ENTRY(
|
||||||
|
item, item.category, request_context, updated, which, ignore_count=ignore_count, add_kind=which != item.category))
|
||||||
|
|
||||||
|
class CategoryGroupFeed(NavFeed):
|
||||||
|
|
||||||
|
def __init__(self, items, which, id_, updated, request_context, offsets, page_url, up_url, title=None):
|
||||||
|
NavFeed.__init__(self, id_, updated, request_context, offsets, page_url, up_url, title=title)
|
||||||
|
for item in items:
|
||||||
|
self.root.append(CATALOG_GROUP_ENTRY(item, which, request_context, updated))
|
||||||
|
|
||||||
|
class RequestContext(object):
|
||||||
|
|
||||||
|
def __init__(self, ctx, rd):
|
||||||
|
self.db, self.library_id, self.library_map, self.default_library = get_library_data(ctx, rd.query)
|
||||||
|
self.ctx, self.rd = ctx, rd
|
||||||
|
|
||||||
|
def url_for(self, path, **kwargs):
|
||||||
|
lid = kwargs.pop('library_id', self.library_id)
|
||||||
|
ans = self.ctx.url_for(path, **kwargs)
|
||||||
|
q = {'library_id':lid}
|
||||||
|
ans += '?' + urlencode(q)
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def allowed_book_ids(self):
|
||||||
|
return self.ctx.allowed_book_ids(self.rd, self.db)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outheaders(self):
|
||||||
|
return self.rd.outheaders
|
||||||
|
|
||||||
|
@property
|
||||||
|
def opts(self):
|
||||||
|
return self.ctx.opts
|
||||||
|
|
||||||
|
def last_modified(self):
|
||||||
|
return self.db.last_modified()
|
||||||
|
|
||||||
|
def get_categories(self):
|
||||||
|
return self.ctx.get_categories(self.rd, self.db)
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
return self.ctx.search(self.rd, self.db, query)
|
||||||
|
|
||||||
|
def get_acquisition_feed(rc, ids, offset, page_url, up_url, id_,
|
||||||
|
sort_by='title', ascending=True, feed_title=None):
|
||||||
|
if not ids:
|
||||||
|
raise HTTPNotFound('No books found')
|
||||||
|
with rc.db.safe_read_lock:
|
||||||
|
items = rc.db.multisort([(sort_by, ascending)], ids)
|
||||||
|
max_items = rc.opts.max_opds_items
|
||||||
|
offsets = Offsets(offset, max_items, len(items))
|
||||||
|
items = items[offsets.offset:offsets.offset+max_items]
|
||||||
|
lm = rc.last_modified()
|
||||||
|
rc.outheaders['Last-Modified'] = http_date(timestampfromdt(lm))
|
||||||
|
return AcquisitionFeed(id_, lm, rc, items, offsets, page_url, up_url, title=feed_title).root
|
||||||
|
|
||||||
|
def get_all_books(rc, which, page_url, up_url, offset=0):
|
||||||
|
try:
|
||||||
|
offset = int(offset)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPNotFound('Not found')
|
||||||
|
if which not in ('title', 'newest'):
|
||||||
|
raise HTTPNotFound('Not found')
|
||||||
|
sort = 'timestamp' if which == 'newest' else 'title'
|
||||||
|
ascending = which == 'title'
|
||||||
|
feed_title = {'newest':_('Newest'), 'title': _('Title')}.get(which, which)
|
||||||
|
feed_title = default_feed_title + ' :: ' + _('By %s') % feed_title
|
||||||
|
ids = rc.allowed_book_ids()
|
||||||
|
return get_acquisition_feed(rc, ids, offset, page_url, up_url,
|
||||||
|
id_='calibre-all:'+sort, sort_by=sort, ascending=ascending,
|
||||||
|
feed_title=feed_title)
|
||||||
|
|
||||||
|
|
||||||
|
def get_navcatalog(request_context, which, page_url, up_url, offset=0):
|
||||||
|
categories = request_context.get_categories()
|
||||||
|
if which not in categories:
|
||||||
|
raise HTTPNotFound('Category %r not found'%which)
|
||||||
|
|
||||||
|
items = categories[which]
|
||||||
|
updated = request_context.last_modified()
|
||||||
|
category_meta = request_context.db.field_metadata
|
||||||
|
meta = category_meta.get(which, {})
|
||||||
|
category_name = meta.get('name', which)
|
||||||
|
feed_title = default_feed_title + ' :: ' + _('By %s') % category_name
|
||||||
|
|
||||||
|
id_ = 'calibre-category-feed:'+which
|
||||||
|
|
||||||
|
MAX_ITEMS = request_context.opts.max_opds_ungrouped_items
|
||||||
|
|
||||||
|
if MAX_ITEMS > 0 and len(items) <= MAX_ITEMS:
|
||||||
|
max_items = request_context.opts.max_opds_items
|
||||||
|
offsets = Offsets(offset, max_items, len(items))
|
||||||
|
items = list(items)[offsets.offset:offsets.offset+max_items]
|
||||||
|
ans = CategoryFeed(items, which, id_, updated, request_context, offsets,
|
||||||
|
page_url, up_url, title=feed_title)
|
||||||
|
else:
|
||||||
|
Group = namedtuple('Group', 'text count')
|
||||||
|
starts = set()
|
||||||
|
for x in items:
|
||||||
|
val = getattr(x, 'sort', x.name)
|
||||||
|
if not val:
|
||||||
|
val = 'A'
|
||||||
|
starts.add(val[0].upper())
|
||||||
|
category_groups = OrderedDict()
|
||||||
|
for x in sorted(starts, key=sort_key):
|
||||||
|
category_groups[x] = len([y for y in items if
|
||||||
|
getattr(y, 'sort', y.name).startswith(x)])
|
||||||
|
items = [Group(x, y) for x, y in category_groups.items()]
|
||||||
|
max_items = request_context.opts.max_opds_items
|
||||||
|
offsets = Offsets(offset, max_items, len(items))
|
||||||
|
items = items[offsets.offset:offsets.offset+max_items]
|
||||||
|
ans = CategoryGroupFeed(items, which, id_, updated, request_context, offsets,
|
||||||
|
page_url, up_url, title=feed_title)
|
||||||
|
|
||||||
|
request_context.outheaders['Last-Modified'] = http_date(timestampfromdt(updated))
|
||||||
|
|
||||||
|
return ans.root
|
||||||
|
|
||||||
|
@endpoint('/opds', postprocess=atom)
|
||||||
|
def opds(ctx, rd):
|
||||||
|
rc = RequestContext(ctx, rd)
|
||||||
|
db = rc.db
|
||||||
|
categories = rc.get_categories()
|
||||||
|
category_meta = db.field_metadata
|
||||||
|
cats = [
|
||||||
|
(_('Newest'), _('Date'), 'Onewest'),
|
||||||
|
(_('Title'), _('Title'), 'Otitle'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def getter(x):
|
||||||
|
try:
|
||||||
|
return category_meta[x]['name'].lower()
|
||||||
|
except KeyError:
|
||||||
|
return x
|
||||||
|
|
||||||
|
for category in sorted(categories, key=lambda x: sort_key(getter(x))):
|
||||||
|
if len(categories[category]) == 0:
|
||||||
|
continue
|
||||||
|
if category in ('formats', 'identifiers'):
|
||||||
|
continue
|
||||||
|
meta = category_meta.get(category, None)
|
||||||
|
if meta is None:
|
||||||
|
continue
|
||||||
|
cats.append((meta['name'], meta['name'], 'N'+category))
|
||||||
|
last_modified = db.last_modified()
|
||||||
|
rd.outheaders['Last-Modified'] = http_date(timestampfromdt(last_modified))
|
||||||
|
return TopLevel(last_modified, cats, rc).root
|
||||||
|
|
||||||
|
@endpoint('/opds/navcatalog/{which}', postprocess=atom)
|
||||||
|
def opds_navcatalog(ctx, rd, which):
|
||||||
|
try:
|
||||||
|
offset = int(rd.query.get('offset', 0))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPNotFound('Not found')
|
||||||
|
rc = RequestContext(ctx, rd)
|
||||||
|
|
||||||
|
page_url = rc.url_for('/opds/navcatalog', which=which)
|
||||||
|
up_url = rc.url_for('/opds')
|
||||||
|
which = unhexlify(which)
|
||||||
|
type_ = which[0]
|
||||||
|
which = which[1:]
|
||||||
|
if type_ == 'O':
|
||||||
|
return get_all_books(rc, which, page_url, up_url, offset=offset)
|
||||||
|
elif type_ == 'N':
|
||||||
|
return get_navcatalog(rc, which, page_url, up_url, offset=offset)
|
||||||
|
raise HTTPNotFound('Not found')
|
||||||
|
|
||||||
|
@endpoint('/opds/category/{category}/{which}', postprocess=atom)
|
||||||
|
def opds_category(ctx, rd, category, which):
|
||||||
|
try:
|
||||||
|
offset = int(rd.query.get('offset', 0))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPNotFound('Not found')
|
||||||
|
|
||||||
|
if not which or not category:
|
||||||
|
raise HTTPNotFound('Not found')
|
||||||
|
rc = RequestContext(ctx, rd)
|
||||||
|
page_url = rc.url_for('/opds/category', which=which, category=category)
|
||||||
|
up_url = rc.url_for('/opds/navcatalog', which=category)
|
||||||
|
|
||||||
|
which, category = unhexlify(which), unhexlify(category)
|
||||||
|
type_ = which[0]
|
||||||
|
which = which[1:]
|
||||||
|
if type_ == 'I':
|
||||||
|
try:
|
||||||
|
p = which.rindex(':')
|
||||||
|
category = which[p+1:]
|
||||||
|
which = which[:p]
|
||||||
|
# This line will toss an exception for composite columns
|
||||||
|
which = int(which[:p])
|
||||||
|
except Exception:
|
||||||
|
# Might be a composite column, where we have the lookup key
|
||||||
|
if not (category in rc.db.field_metadata and
|
||||||
|
rc.db.field_metadata[category]['datatype'] == 'composite'):
|
||||||
|
raise HTTPNotFound('Tag %r not found'%which)
|
||||||
|
|
||||||
|
categories = rc.get_categories()
|
||||||
|
if category not in categories:
|
||||||
|
raise HTTPNotFound('Category %r not found'%which)
|
||||||
|
|
||||||
|
if category == 'search':
|
||||||
|
try:
|
||||||
|
ids = rc.search('search:"%s"'%which)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPNotFound('Search: %r not understood'%which)
|
||||||
|
return get_acquisition_feed(rc, ids, offset, page_url, up_url, 'calibre-search:'+which)
|
||||||
|
|
||||||
|
if type_ != 'I':
|
||||||
|
raise HTTPNotFound('Non id categories not supported')
|
||||||
|
|
||||||
|
q = category
|
||||||
|
if q == 'news':
|
||||||
|
q = 'tags'
|
||||||
|
ids = rc.db.get_books_for_category(q, which)
|
||||||
|
sort_by = 'series' if category == 'series' else 'title'
|
||||||
|
|
||||||
|
return get_acquisition_feed(rc, ids, offset, page_url, up_url, 'calibre-category:'+category+':'+str(which), sort_by=sort_by)
|
||||||
|
|
||||||
|
|
||||||
|
@endpoint('/opds/categorygroup/{category}/{which}', postprocess=atom)
|
||||||
|
def opds_categorygroup(ctx, rd, category, which):
|
||||||
|
try:
|
||||||
|
offset = int(rd.query.get('offset', 0))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPNotFound('Not found')
|
||||||
|
|
||||||
|
if not which or not category:
|
||||||
|
raise HTTPNotFound('Not found')
|
||||||
|
|
||||||
|
rc = RequestContext(ctx, rd)
|
||||||
|
categories = rc.get_categories()
|
||||||
|
page_url = rc.url_for('/opds/categorygroup', category=category, which=which)
|
||||||
|
|
||||||
|
category = unhexlify(category)
|
||||||
|
if category not in categories:
|
||||||
|
raise HTTPNotFound('Category %r not found'%which)
|
||||||
|
category_meta = rc.db.field_metadata
|
||||||
|
meta = category_meta.get(category, {})
|
||||||
|
category_name = meta.get('name', which)
|
||||||
|
which = unhexlify(which)
|
||||||
|
feed_title = default_feed_title + ' :: ' + (_('By {0} :: {1}').format(category_name, which))
|
||||||
|
owhich = hexlify('N'+which)
|
||||||
|
up_url = rc.url_for('/opds/navcatalog', which=owhich)
|
||||||
|
items = categories[category]
|
||||||
|
def belongs(x, which):
|
||||||
|
return getattr(x, 'sort', x.name).lower().startswith(which.lower())
|
||||||
|
items = [x for x in items if belongs(x, which)]
|
||||||
|
if not items:
|
||||||
|
raise HTTPNotFound('No items in group %r:%r'%(category, which))
|
||||||
|
updated = rc.last_modified()
|
||||||
|
|
||||||
|
id_ = 'calibre-category-group-feed:'+category+':'+which
|
||||||
|
|
||||||
|
max_items = rc.opts.max_opds_items
|
||||||
|
offsets = Offsets(offset, max_items, len(items))
|
||||||
|
items = list(items)[offsets.offset:offsets.offset+max_items]
|
||||||
|
|
||||||
|
rc.outheaders['Last-Modified'] = http_date(timestampfromdt(updated))
|
||||||
|
|
||||||
|
return CategoryFeed(items, category, id_, updated, rc, offsets, page_url, up_url, title=feed_title).root
|
||||||
|
|
||||||
|
@endpoint('/opds/search/{query=""}', postprocess=atom)
|
||||||
|
def opds_search(ctx, rd, query):
|
||||||
|
try:
|
||||||
|
offset = int(rd.query.get('offset', 0))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPNotFound('Not found')
|
||||||
|
|
||||||
|
rc = RequestContext(ctx, rd)
|
||||||
|
try:
|
||||||
|
ids = rc.search(query)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPNotFound('Search: %r not understood'%query)
|
||||||
|
page_url = rc.url_for('/opds/search', query=query)
|
||||||
|
return get_acquisition_feed(rc, ids, offset, page_url, rc.url_for('/opds'), 'calibre-search:'+query)
|
@ -70,6 +70,16 @@ raw_options = (
|
|||||||
'Advertise the OPDS feeds via the BonJour service, so that OPDS based'
|
'Advertise the OPDS feeds via the BonJour service, so that OPDS based'
|
||||||
' reading apps can detect and connect to the server automatically.',
|
' reading apps can detect and connect to the server automatically.',
|
||||||
|
|
||||||
|
'Maximum number of books in OPDS feeds',
|
||||||
|
'max_opds_items', 30,
|
||||||
|
'The maximum number of books that the server will return in a single'
|
||||||
|
' OPDS acquisition feed.',
|
||||||
|
|
||||||
|
'Maximum number of ungrouped items in OPDS feeds',
|
||||||
|
'max_opds_ungrouped_items', 100,
|
||||||
|
'Group items in categories such as author/tags by first letter when'
|
||||||
|
' there are more than this number of items. Set to zero to disable.',
|
||||||
|
|
||||||
'The interface on which to listen for connections',
|
'The interface on which to listen for connections',
|
||||||
'listen_on', '0.0.0.0',
|
'listen_on', '0.0.0.0',
|
||||||
'The default is to listen on all available interfaces. You can change this to, for'
|
'The default is to listen on all available interfaces. You can change this to, for'
|
||||||
|
@ -19,6 +19,7 @@ from binascii import hexlify, unhexlify
|
|||||||
|
|
||||||
from calibre import prints
|
from calibre import prints
|
||||||
from calibre.constants import iswindows
|
from calibre.constants import iswindows
|
||||||
|
from calibre.srv.errors import HTTPNotFound
|
||||||
from calibre.utils.config_base import tweaks
|
from calibre.utils.config_base import tweaks
|
||||||
from calibre.utils.localization import get_translator
|
from calibre.utils.localization import get_translator
|
||||||
from calibre.utils.socket_inheritance import set_socket_inherit
|
from calibre.utils.socket_inheritance import set_socket_inherit
|
||||||
@ -469,3 +470,38 @@ class ReadOnlyFileBuffer(object):
|
|||||||
def close(self):
|
def close(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def get_db(ctx, library_id):
|
||||||
|
db = ctx.get_library(library_id)
|
||||||
|
if db is None:
|
||||||
|
raise HTTPNotFound('Library %r not found' % library_id)
|
||||||
|
return db
|
||||||
|
|
||||||
|
def get_library_data(ctx, query):
|
||||||
|
library_id = query.get('library_id')
|
||||||
|
library_map, default_library = ctx.library_map
|
||||||
|
if library_id not in library_map:
|
||||||
|
library_id = default_library
|
||||||
|
db = get_db(ctx, library_id)
|
||||||
|
return db, library_id, library_map, default_library
|
||||||
|
|
||||||
|
class Offsets(object):
|
||||||
|
'Calculate offsets for a paginated view'
|
||||||
|
|
||||||
|
def __init__(self, offset, delta, total):
|
||||||
|
if offset < 0:
|
||||||
|
offset = 0
|
||||||
|
if offset >= total:
|
||||||
|
raise HTTPNotFound('Invalid offset: %r'%offset)
|
||||||
|
last_allowed_index = total - 1
|
||||||
|
last_current_index = offset + delta - 1
|
||||||
|
self.slice_upper_bound = offset+delta
|
||||||
|
self.offset = offset
|
||||||
|
self.next_offset = last_current_index + 1
|
||||||
|
if self.next_offset > last_allowed_index:
|
||||||
|
self.next_offset = -1
|
||||||
|
self.previous_offset = self.offset - delta
|
||||||
|
if self.previous_offset < 0:
|
||||||
|
self.previous_offset = 0
|
||||||
|
self.last_offset = last_allowed_index - delta
|
||||||
|
if self.last_offset < 0:
|
||||||
|
self.last_offset = 0
|
||||||
|
Loading…
x
Reference in New Issue
Block a user