mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-07 10:14:46 -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
|
||||
|
||||
|
@ -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]
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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'))
|
||||
|
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'
|
||||
' 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'
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user