From 2ef515b6c04a23354480aafa27d98b5ac2fd1a94 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 19 Feb 2016 17:23:18 +0530 Subject: [PATCH] CS: Implement /opds Structure remains the same as the old server, except that there is support for multiple libraries --- src/calibre/srv/TODO | 2 +- src/calibre/srv/ajax.py | 8 +- src/calibre/srv/code.py | 11 +- src/calibre/srv/handler.py | 2 +- src/calibre/srv/legacy.py | 7 +- src/calibre/srv/opds.py | 592 +++++++++++++++++++++++++++++++++++++ src/calibre/srv/opts.py | 10 + src/calibre/srv/utils.py | 36 +++ 8 files changed, 648 insertions(+), 20 deletions(-) create mode 100644 src/calibre/srv/opds.py diff --git a/src/calibre/srv/TODO b/src/calibre/srv/TODO index 59706a57f4..10e65b3dac 100644 --- a/src/calibre/srv/TODO +++ b/src/calibre/srv/TODO @@ -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 diff --git a/src/calibre/srv/ajax.py b/src/calibre/srv/ajax.py index c8b43d45dc..356366805a 100644 --- a/src/calibre/srv/ajax.py +++ b/src/calibre/srv/ajax.py @@ -18,7 +18,7 @@ from calibre.ebooks.metadata.book.json_codec import JsonCodec from calibre.srv.errors import HTTPNotFound from calibre.srv.routes import endpoint, json 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.date import isoformat, timestampfromdt 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") 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): # {{{ if category in category_icon_map: icon = category_icon_map[category] diff --git a/src/calibre/srv/code.py b/src/calibre/srv/code.py index fd026f0cd1..3a22e76cac 100644 --- a/src/calibre/srv/code.py +++ b/src/calibre/srv/code.py @@ -13,10 +13,11 @@ from calibre import prepare_string_for_xml, as_unicode from calibre.constants import config_dir from calibre.customize.ui import available_input_formats 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.metadata import book_as_json, categories_as_json, icon_map 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.icu import sort_key from calibre.utils.search_query_parser import ParseException @@ -61,14 +62,6 @@ def index(ctx, rd): 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): db, library_id, library_map, default_library = get_library_data(ctx, query) skeys = db.field_metadata.sortable_field_keys() diff --git a/src/calibre/srv/handler.py b/src/calibre/srv/handler.py index c657be4300..8f5f5963d2 100644 --- a/src/calibre/srv/handler.py +++ b/src/calibre/srv/handler.py @@ -173,7 +173,7 @@ class Handler(object): 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.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) self.router.load_routes(vars(module).itervalues()) self.router.finalize() diff --git a/src/calibre/srv/legacy.py b/src/calibre/srv/legacy.py index 4eb0b9b741..73fead2dd8 100644 --- a/src/calibre/srv/legacy.py +++ b/src/calibre/srv/legacy.py @@ -11,9 +11,12 @@ from calibre.srv.routes import endpoint @endpoint('/browse/{+rest=""}') def browse(ctx, rd, rest): - raise HTTPRedirect(ctx.url_for('') or '/') + raise HTTPRedirect(ctx.url_for(None)) @endpoint('/mobile/{+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')) diff --git a/src/calibre/srv/opds.py b/src/calibre/srv/opds.py new file mode 100644 index 0000000000..a7da8e3615 --- /dev/null +++ b/src/calibre/srv/opds.py @@ -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 ' +__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'
%s
'%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
')%rating) + if mi.tags: + extra.append(_('TAGS: %s
')%xml(format_tag_string(mi.tags, None, no_tag_count=True))) + if mi.series: + extra.append(_('SERIES: %(series)s [%(sidx)s]
')% + 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
'% + (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
'%(xml(name), comments_to_html(unicode(val)))) + else: + extra.append('%s: %s
'%(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) diff --git a/src/calibre/srv/opts.py b/src/calibre/srv/opts.py index 48c5107055..cc84ff1cb6 100644 --- a/src/calibre/srv/opts.py +++ b/src/calibre/srv/opts.py @@ -70,6 +70,16 @@ raw_options = ( 'Advertise the OPDS feeds via the BonJour service, so that OPDS based' ' 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', 'listen_on', '0.0.0.0', 'The default is to listen on all available interfaces. You can change this to, for' diff --git a/src/calibre/srv/utils.py b/src/calibre/srv/utils.py index 63765cd969..e887c6c998 100644 --- a/src/calibre/srv/utils.py +++ b/src/calibre/srv/utils.py @@ -19,6 +19,7 @@ from binascii import hexlify, unhexlify from calibre import prints from calibre.constants import iswindows +from calibre.srv.errors import HTTPNotFound from calibre.utils.config_base import tweaks from calibre.utils.localization import get_translator from calibre.utils.socket_inheritance import set_socket_inherit @@ -469,3 +470,38 @@ class ReadOnlyFileBuffer(object): def close(self): 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