Remove the old server code. Still need to port various bits of calibre that use it

This commit is contained in:
Kovid Goyal 2017-04-07 11:29:58 +05:30
parent fce59c91c7
commit bae7d21608
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
14 changed files with 5 additions and 3785 deletions

View File

@ -60,7 +60,7 @@ All the calibre python code is in the ``calibre`` package. This package contains
* db - The database back-end. See :ref:`db_api` for the interface to the calibre library.
* Content server: ``library.server`` is the calibre Content server.
* Content server: ``srv`` is the calibre Content server.
* gui2 - The Graphical User Interface. GUI initialization happens in ``gui2.main`` and ``gui2.ui``. The e-book-viewer is in ``gui2.viewer``. The e-book editor is in ``gui2.tweak_book``.

View File

@ -13,7 +13,6 @@ from calibre.constants import get_osx_version, isosx, iswindows
from calibre.gui2 import info_dialog, question_dialog
from calibre.gui2.actions import InterfaceAction
from calibre.gui2.dialogs.smartdevice import SmartdeviceDialog
from calibre.library.server import server_config as content_server_config
from calibre.utils.config import tweaks
from calibre.utils.icu import primary_sort_key
from calibre.utils.smtp import config as email_config
@ -80,11 +79,12 @@ class ShareConnMenu(QMenu): # {{{
if running:
listen_on = (verify_ipV4_address(tweaks['server_listen_on']) or
get_external_ip())
try :
try:
from calibre.library.server import server_config as content_server_config
cs_port = content_server_config().parse().port
ip_text = _(' [%(ip)s, port %(port)d]')%dict(ip=listen_on,
port=cs_port)
except:
except Exception:
ip_text = ' [%s]'%listen_on
text = _('Stop Content server') + ip_text
self.toggle_server_action.setText(text)

View File

@ -1,67 +0,0 @@
#!/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 os
from calibre.utils.config_base import Config, StringConfig, config_dir, tweaks
listen_on = tweaks['server_listen_on']
log_access_file = os.path.join(config_dir, 'server_access_log.txt')
log_error_file = os.path.join(config_dir, 'server_error_log.txt')
def server_config(defaults=None):
desc=_('Settings to control the calibre content server')
c = Config('server', desc) if defaults is None else StringConfig(defaults, desc)
c.add_opt('port', ['-p', '--port'], default=8080,
help=_('The port on which to listen. Default is %default'))
c.add_opt('timeout', ['-t', '--timeout'], default=120,
help=_('The server timeout in seconds. Default is %default'))
c.add_opt('thread_pool', ['--thread-pool'], default=30,
help=_('The max number of worker threads to use. Default is %default'))
c.add_opt('password', ['--password'], default=None,
help=_('Set a password to restrict access. By default access is unrestricted.'))
c.add_opt('username', ['--username'], default='calibre',
help=_('Username for access. By default, it is: %default'))
c.add_opt('develop', ['--develop'], default=False,
help=_('Development mode. Server logs to stdout, with more verbose logging and has much lower timeouts.')) # noqa
c.add_opt('max_cover', ['--max-cover'], default='600x800',
help=_('The maximum size for displayed covers. Default is %default.'))
c.add_opt('max_opds_items', ['--max-opds-items'], default=30,
help=_('The maximum number of matches to return per OPDS query. '
'This affects Stanza, WordPlayer, etc. integration.'))
c.add_opt('max_opds_ungrouped_items', ['--max-opds-ungrouped-items'],
default=100,
help=_('Group items in categories such as author/tags '
'by first letter when there are more than this number '
'of items. Default: %default. Set to a large number '
'to disable grouping.'))
c.add_opt('url_prefix', ['--url-prefix'], default='',
help=_('Prefix to prepend to all URLs. Useful for reverse'
'proxying to this server from Apache/nginx/etc.'))
return c
def custom_fields_to_display(db):
ckeys = set(db.field_metadata.ignorable_field_keys())
yes_fields = set(tweaks['content_server_will_display'])
no_fields = set(tweaks['content_server_wont_display'])
if '*' in yes_fields:
yes_fields = ckeys
if '*' in no_fields:
no_fields = ckeys
return frozenset(ckeys & (yes_fields - no_fields))
def main():
from calibre.library.server.main import main
return main()

View File

@ -1,644 +0,0 @@
#!/usr/bin/env python2
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
from __future__ import (unicode_literals, division, absolute_import,
print_function)
from future_builtins import map
__license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import json
from functools import wraps
from binascii import hexlify, unhexlify
import cherrypy
from calibre.utils.date import isoformat
from calibre.utils.config import prefs, tweaks
from calibre.ebooks.metadata import title_sort
from calibre.ebooks.metadata.book.json_codec import JsonCodec
from calibre.utils.icu import sort_key
from calibre.library.server import custom_fields_to_display
from calibre import force_unicode, isbytestring
from calibre.library.field_metadata import category_icon_map
class Endpoint(object): # {{{
'Manage mime-type json serialization, etc.'
def __init__(self, mimetype='application/json; charset=utf-8',
set_last_modified=True):
self.mimetype = mimetype
self.set_last_modified = set_last_modified
def __call__(eself, func):
@wraps(func)
def wrapper(self, *args, **kwargs):
# Remove AJAX caching disabling jquery workaround arg
# This arg is put into AJAX queries by jQuery to prevent
# caching in the browser. We dont want it passed to the wrapped
# function
kwargs.pop('_', None)
ans = func(self, *args, **kwargs)
cherrypy.response.headers['Content-Type'] = eself.mimetype
if eself.set_last_modified:
updated = self.db.last_modified()
cherrypy.response.headers['Last-Modified'] = \
self.last_modified(max(updated, self.build_time))
if 'application/json' in eself.mimetype:
ans = json.dumps(ans, indent=2,
ensure_ascii=False).encode('utf-8')
return ans
return wrapper
# }}}
def category_icon(category, meta): # {{{
if category in category_icon_map:
icon = category_icon_map[category]
elif meta['is_custom']:
icon = category_icon_map['custom:']
elif meta['kind'] == 'user':
icon = category_icon_map['user:']
else:
icon = 'blank.png'
return icon
# }}}
# URL Encoding {{{
def encode_name(name):
if isinstance(name, unicode):
name = name.encode('utf-8')
return hexlify(name)
def decode_name(name):
return unhexlify(name).decode('utf-8')
def absurl(prefix, url):
return prefix + url
def category_url(prefix, cid):
return absurl(prefix, '/ajax/category/'+encode_name(cid))
def icon_url(prefix, name):
return absurl(prefix, '/browse/icon/'+name)
def books_in_url(prefix, category, cid):
return absurl(prefix, '/ajax/books_in/%s/%s'%(
encode_name(category), encode_name(cid)))
# }}}
class AjaxServer(object):
def __init__(self):
self.ajax_json_codec = JsonCodec()
def add_routes(self, connect):
base_href = '/ajax'
# Metadata for books
connect('ajax_book', base_href+'/book/{book_id}', self.ajax_book)
connect('ajax_books', base_href+'/books', self.ajax_books)
# The list of top level categories
connect('ajax_categories', base_href+'/categories',
self.ajax_categories)
# The list of sub-categories and items in each category
connect('ajax_category', base_href+'/category/{name}',
self.ajax_category)
# List of books in specified category
connect('ajax_books_in', base_href+'/books_in/{category}/{item}',
self.ajax_books_in)
# Search
connect('ajax_search', base_href+'/search', self.ajax_search)
# Get book metadata {{{
def ajax_book_to_json(self, book_id, get_category_urls=True,
device_compatible=False, device_for_template=None):
mi = self.db.get_metadata(book_id, index_is_id=True)
if not device_compatible:
try:
mi.rating = mi.rating/2.
except:
mi.rating = 0.0
data = self.ajax_json_codec.encode_book_metadata(mi)
for x in ('publication_type', 'size', 'db_id', 'lpath', 'mime',
'rights', 'book_producer'):
data.pop(x, None)
data['cover'] = absurl(self.opts.url_prefix, u'/get/cover/%d'%book_id)
data['thumbnail'] = absurl(self.opts.url_prefix, u'/get/thumb/%d'%book_id)
if not device_compatible:
mi.format_metadata = {k.lower():dict(v) for k, v in
mi.format_metadata.iteritems()}
for v in mi.format_metadata.itervalues():
mtime = v.get('mtime', None)
if mtime is not None:
v['mtime'] = isoformat(mtime, as_utc=True)
data['format_metadata'] = mi.format_metadata
fmts = set(x.lower() for x in mi.format_metadata.iterkeys())
pf = prefs['output_format'].lower()
other_fmts = list(fmts)
try:
fmt = pf if pf in fmts else other_fmts[0]
except:
fmt = None
if fmts and fmt:
other_fmts = [x for x in fmts if x != fmt]
data['formats'] = sorted(fmts)
if fmt:
data['main_format'] = {fmt: absurl(self.opts.url_prefix, u'/get/%s/%d'%(fmt, book_id))}
else:
data['main_format'] = None
data['other_formats'] = {fmt: absurl(self.opts.url_prefix, u'/get/%s/%d'%(fmt, book_id)) for fmt
in other_fmts}
if get_category_urls:
category_urls = data['category_urls'] = {}
ccache = self.categories_cache()
for key in mi.all_field_keys():
fm = mi.metadata_for_field(key)
if (fm and fm['is_category'] and not fm['is_csp'] and
key != 'formats' and fm['datatype'] not in ['rating']):
categories = mi.get(key)
if isinstance(categories, basestring):
categories = [categories]
if categories is None:
categories = []
dbtags = {}
for category in categories:
for tag in ccache.get(key, []):
if tag.original_name == category:
dbtags[category] = books_in_url(self.opts.url_prefix,
tag.category if tag.category else key,
tag.original_name if tag.id is None else
unicode(tag.id))
break
category_urls[key] = dbtags
else:
series = data.get('series', None)
if series:
tsorder = tweaks['save_template_title_series_sorting']
series = title_sort(series, order=tsorder)
else:
series = ''
data['_series_sort_'] = series
if device_for_template:
import posixpath
from calibre.devices.utils import create_upload_path
from calibre.utils.filenames import ascii_filename as sanitize
from calibre.customize.ui import device_plugins
for device_class in device_plugins():
if device_class.__class__.__name__ == device_for_template:
template = device_class.save_template()
data['_filename_'] = create_upload_path(mi, book_id,
template, sanitize, path_type=posixpath)
break
return data, mi.last_modified
@Endpoint(set_last_modified=False)
def ajax_book(self, book_id, category_urls='true', id_is_uuid='false',
device_compatible='false', device_for_template=None):
'''
Return the metadata of the book as a JSON dictionary.
If category_urls == 'true' the returned dictionary also contains a
mapping of category names to URLs that return the list of books in the
given category.
'''
cherrypy.response.timeout = 3600
try:
if id_is_uuid == 'true':
book_id = self.db.get_id_from_uuid(book_id)
else:
book_id = int(book_id)
data, last_modified = self.ajax_book_to_json(book_id,
get_category_urls=category_urls.lower()=='true',
device_compatible=device_compatible.lower()=='true',
device_for_template=device_for_template)
except:
raise cherrypy.HTTPError(404, 'No book with id: %r'%book_id)
cherrypy.response.headers['Last-Modified'] = \
self.last_modified(last_modified)
return data
@Endpoint(set_last_modified=False)
def ajax_books(self, ids=None, category_urls='true', id_is_uuid='false', device_for_template=None):
'''
Return the metadata for a list of books specified as a comma separated
list of ids. The metadata is returned as a dictionary mapping ids to
the metadata. The format for the metadata is the same as in
ajax_book(). If no book is found for a given id, it is mapped to null
in the dictionary.
This endpoint can be used with either GET or POST requests, variable
name is ids: /ajax/books?ids=1,2,3,4,5
'''
if ids is None:
raise cherrypy.HTTPError(404, 'Must specify some ids')
try:
if id_is_uuid == 'true':
ids = set(self.db.get_id_from_uuid(x) for x in ids.split(','))
else:
ids = set(int(x.strip()) for x in ids.split(','))
except:
raise cherrypy.HTTPError(404, 'ids must be a comma separated list'
' of integers')
ans = {}
lm = None
gcu = category_urls.lower()=='true'
for book_id in ids:
try:
data, last_modified = self.ajax_book_to_json(book_id,
get_category_urls=gcu, device_for_template=device_for_template)
except:
ans[book_id] = None
else:
ans[book_id] = data
if lm is None or last_modified > lm:
lm = last_modified
cherrypy.response.timeout = 3600
cherrypy.response.headers['Last-Modified'] = \
self.last_modified(lm if lm is not None else
self.db.last_modified())
return ans
# }}}
# Top level categories {{{
@Endpoint()
def ajax_categories(self):
'''
Return the list of top-level categories as a list of dictionaries. Each
dictionary is of the form::
{
'name': Display Name,
'url':URL that gives the JSON object corresponding to all entries in this category,
'icon': URL to icon of this category,
'is_category': False for the All Books and Newest categories, True for everything else
}
'''
ans = {}
categories = self.categories_cache()
category_meta = self.db.field_metadata
def getter(x):
return category_meta[x]['name']
displayed_custom_fields = custom_fields_to_display(self.db)
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
if category_meta.is_ignorable_field(category) and \
category not in displayed_custom_fields:
continue
display_name = meta['name']
if category.startswith('@'):
category = category.partition('.')[0]
display_name = category[1:]
url = force_unicode(category)
icon = category_icon(category, meta)
ans[url] = (display_name, icon)
ans = [{'url':k, 'name':v[0], 'icon':v[1], 'is_category':True}
for k, v in ans.iteritems()]
ans.sort(key=lambda x: sort_key(x['name']))
for name, url, icon in [
(_('All books'), 'allbooks', 'book.png'),
(_('Newest'), 'newest', 'forward.png'),
]:
ans.insert(0, {'name':name, 'url':url, 'icon':icon,
'is_category':False})
for c in ans:
c['url'] = category_url(self.opts.url_prefix, c['url'])
c['icon'] = icon_url(self.opts.url_prefix, c['icon'])
return ans
# }}}
# Items in the specified category {{{
@Endpoint()
def ajax_category(self, name, sort='title', num=100, offset=0,
sort_order='asc'):
'''
Return a dictionary describing the category specified by name. The
dictionary looks like::
{
'category_name': Category display name,
'base_url': Base URL for this category,
'total_num': Total numberof items in this category,
'offset': The offset for the items returned in this result,
'num': The number of items returned in this result,
'sort': How the returned items are sorted,
'sort_order': asc or desc
'subcategories': List of sub categories of this category.
'items': List of items in this category,
}
Each subcategory is a dictionary of the same form as those returned by
ajax_categories().
Each item is a dictionary of the form::
{
'name': Display name,
'average_rating': Average rating for books in this item,
'count': Number of books in this item,
'url': URL to get list of books in this item,
'has_children': If True this item contains sub categories, look
for an entry corresponding to this item in subcategories in the
main dictionary,
}
:param sort: How to sort the returned items. Choices are: name, rating,
popularity
:param sort_order: asc or desc
To learn how to create subcategories see
https://manual.calibre-ebook.com/sub_groups.html
'''
try:
num = int(num)
except:
raise cherrypy.HTTPError(404, "Invalid num: %r"%num)
try:
offset = int(offset)
except:
raise cherrypy.HTTPError(404, "Invalid offset: %r"%offset)
base_url = absurl(self.opts.url_prefix, '/ajax/category/'+name)
if sort not in ('rating', 'name', 'popularity'):
sort = 'name'
if sort_order not in ('asc', 'desc'):
sort_order = 'asc'
try:
dname = decode_name(name)
except:
raise cherrypy.HTTPError(404, 'Invalid encoding of category name'
' %r'%name)
if dname in ('newest', 'allbooks'):
if dname == 'newest':
sort, sort_order = 'timestamp', 'desc'
raise cherrypy.InternalRedirect(
'/ajax/books_in/%s/%s?sort=%s&sort_order=%s'%(
encode_name(dname), encode_name('0'), sort, sort_order))
fm = self.db.field_metadata
categories = self.categories_cache()
hierarchical_categories = self.db.prefs['categories_using_hierarchy']
subcategory = dname
toplevel = subcategory.partition('.')[0]
if toplevel == subcategory:
subcategory = None
if toplevel not in categories or toplevel not in fm:
raise cherrypy.HTTPError(404, 'Category %r not found'%toplevel)
# Find items and sub categories
subcategories = []
meta = fm[toplevel]
item_names = {}
children = set()
if meta['kind'] == 'user':
fullname = ((toplevel + '.' + subcategory) if subcategory is not
None else toplevel)
try:
# User categories cannot be applied to books, so this is the
# complete set of items, no need to consider sub categories
items = categories[fullname]
except:
raise cherrypy.HTTPError(404,
'User category %r not found'%fullname)
parts = fullname.split('.')
for candidate in categories:
cparts = candidate.split('.')
if len(cparts) == len(parts)+1 and cparts[:-1] == parts:
subcategories.append({'name':cparts[-1],
'url':candidate,
'icon':category_icon(toplevel, meta)})
category_name = toplevel[1:].split('.')
# When browsing by user categories we ignore hierarchical normal
# columns, so children can be empty
elif toplevel in hierarchical_categories:
items = []
category_names = [x.original_name.split('.') for x in categories[toplevel] if
'.' in x.original_name]
if subcategory is None:
children = set(x[0] for x in category_names)
category_name = [meta['name']]
items = [x for x in categories[toplevel] if '.' not in x.original_name]
else:
subcategory_parts = subcategory.split('.')[1:]
category_name = [meta['name']] + subcategory_parts
lsp = len(subcategory_parts)
children = set('.'.join(x) for x in category_names if len(x) ==
lsp+1 and x[:lsp] == subcategory_parts)
items = [x for x in categories[toplevel] if x.original_name in
children]
item_names = {x:x.original_name.rpartition('.')[-1] for x in
items}
# Only mark the subcategories that have children themselves as
# subcategories
children = set('.'.join(x[:lsp+1]) for x in category_names if len(x) >
lsp+1 and x[:lsp] == subcategory_parts)
subcategories = [{'name':x.rpartition('.')[-1],
'url':toplevel+'.'+x,
'icon':category_icon(toplevel, meta)} for x in children]
else:
items = categories[toplevel]
category_name = meta['name']
for x in subcategories:
x['url'] = category_url(self.opts.url_prefix, x['url'])
x['icon'] = icon_url(self.opts.url_prefix, x['icon'])
x['is_category'] = True
sort_keygen = {
'name': lambda x: sort_key(x.sort if x.sort else x.original_name),
'popularity': lambda x: x.count,
'rating': lambda x: x.avg_rating
}
items.sort(key=sort_keygen[sort], reverse=sort_order == 'desc')
total_num = len(items)
items = items[offset:offset+num]
items = [{
'name':item_names.get(x, x.original_name),
'average_rating': x.avg_rating,
'count': x.count,
'url': books_in_url(self.opts.url_prefix,
x.category if x.category else toplevel,
x.original_name if x.id is None else unicode(x.id)),
'has_children': x.original_name in children,
} for x in items]
return {
'category_name': category_name,
'base_url': base_url,
'total_num': total_num,
'offset':offset, 'num':len(items), 'sort':sort,
'sort_order':sort_order,
'subcategories':subcategories,
'items':items,
}
# }}}
# Books in the specified category {{{
@Endpoint()
def ajax_books_in(self, category, item, sort='title', num=25, offset=0,
sort_order='asc', get_additional_fields=''):
'''
Return the books (as list of ids) present in the specified category.
'''
try:
dname, ditem = map(decode_name, (category, item))
except:
raise cherrypy.HTTPError(404, 'Invalid encoded param: %r'%category)
try:
num = int(num)
except:
raise cherrypy.HTTPError(404, "Invalid num: %r"%num)
try:
offset = int(offset)
except:
raise cherrypy.HTTPError(404, "Invalid offset: %r"%offset)
if sort_order not in ('asc', 'desc'):
sort_order = 'asc'
sfield = self.db.data.sanitize_sort_field_name(sort)
if sfield not in self.db.field_metadata.sortable_field_keys():
raise cherrypy.HTTPError(404, '%s is not a valid sort field'%sort)
if dname in ('allbooks', 'newest'):
ids = self.search_cache('')
elif dname == 'search':
try:
ids = self.search_cache('search:"%s"'%ditem)
except:
raise cherrypy.HTTPError(404, 'Search: %r not understood'%ditem)
else:
try:
cid = int(ditem)
except:
raise cherrypy.HTTPError(404,
'Category id %r not an integer'%ditem)
if dname == 'news':
dname = 'tags'
ids = self.db.get_books_for_category(dname, cid)
all_ids = set(self.search_cache(''))
# Implement restriction
ids = ids.intersection(all_ids)
ids = list(ids)
self.db.data.multisort(fields=[(sfield, sort_order == 'asc')], subsort=True,
only_ids=ids)
total_num = len(ids)
ids = ids[offset:offset+num]
result = {
'total_num': total_num, 'sort_order':sort_order,
'offset':offset, 'num':len(ids), 'sort':sort,
'base_url':absurl(self.opts.url_prefix, '/ajax/books_in/%s/%s'%(category, item)),
'book_ids':ids
}
if get_additional_fields:
additional_fields = {}
for field in get_additional_fields.split(','):
field = field.strip()
if field:
flist = additional_fields[field] = []
for id_ in ids:
flist.append(self.db.new_api.field_for(field, id_,
default_value=None))
if additional_fields:
result['additional_fields'] = additional_fields
return result
# }}}
# Search {{{
@Endpoint()
def ajax_search(self, query='', sort='title', offset=0, num=25,
sort_order='asc'):
'''
Return the books (as list of ids) matching the specified search query.
'''
try:
num = int(num)
except:
raise cherrypy.HTTPError(404, "Invalid num: %r"%num)
try:
offset = int(offset)
except:
raise cherrypy.HTTPError(404, "Invalid offset: %r"%offset)
sfield = self.db.data.sanitize_sort_field_name(sort)
if sfield not in self.db.field_metadata.sortable_field_keys():
raise cherrypy.HTTPError(404, '%s is not a valid sort field'%sort)
if isbytestring(query):
query = query.decode('UTF-8')
ids = list(self.search_for_books(query))
self.db.data.multisort(fields=[(sfield, sort_order == 'asc')], subsort=True,
only_ids=ids)
total_num = len(ids)
ids = ids[offset:offset+num]
return {
'total_num': total_num, 'sort_order':sort_order,
'offset':offset, 'num':len(ids), 'sort':sort,
'base_url':absurl(self.opts.url_prefix, '/ajax/search'),
'query': query,
'book_ids':ids
}
# }}}

View File

@ -1,327 +0,0 @@
#!/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 os
import logging
from logging.handlers import RotatingFileHandler
import cherrypy
from cherrypy.process.plugins import SimplePlugin
from calibre.constants import __appname__, __version__
from calibre.utils.date import fromtimestamp
from calibre.library.server import listen_on, log_access_file, log_error_file
from calibre.library.server.utils import expose, AuthController
from calibre.utils.mdns import publish as publish_zeroconf, \
unpublish as unpublish_zeroconf, get_external_ip, verify_ipV4_address
from calibre.library.server.content import ContentServer
from calibre.library.server.mobile import MobileServer
from calibre.library.server.xml import XMLServer
from calibre.library.server.opds import OPDSServer
from calibre.library.server.cache import Cache
from calibre.library.server.browse import BrowseServer
from calibre.library.server.ajax import AjaxServer
from calibre import prints, as_unicode
class DispatchController(object): # {{{
def __init__(self, prefix, wsgi=False, auth_controller=None):
self.dispatcher = cherrypy.dispatch.RoutesDispatcher()
self.funcs = []
self.seen = set()
self.auth_controller = auth_controller
self.prefix = prefix if prefix else ''
if wsgi:
self.prefix = ''
def __call__(self, name, route, func, **kwargs):
if name in self.seen:
raise NameError('Route name: '+ repr(name) + ' already used')
self.seen.add(name)
kwargs['action'] = 'f_%d'%len(self.funcs)
aw = kwargs.pop('android_workaround', False)
if route != '/':
route = self.prefix + route
if isinstance(route, unicode):
# Apparently the routes package chokes on unicode routes, see
# https://www.mobileread.com/forums/showthread.php?t=235366
route = route.encode('utf-8')
elif self.prefix:
self.dispatcher.connect(name+'prefix_extra', self.prefix, self,
**kwargs)
self.dispatcher.connect(name+'prefix_extra_trailing',
self.prefix+'/', self, **kwargs)
self.dispatcher.connect(name, route, self, **kwargs)
if self.auth_controller is not None:
func = self.auth_controller(func, aw)
self.funcs.append(expose(func))
def __getattr__(self, attr):
if not attr.startswith('f_'):
raise AttributeError(attr + ' not found')
num = attr.rpartition('_')[-1]
try:
num = int(num)
except:
raise AttributeError(attr + ' not found')
if num < 0 or num >= len(self.funcs):
raise AttributeError(attr + ' not found')
return self.funcs[num]
# }}}
class BonJour(SimplePlugin): # {{{
def __init__(self, engine, port=8080, prefix=''):
SimplePlugin.__init__(self, engine)
self.port = port
self.prefix = prefix
self.ip_address = '0.0.0.0'
@property
def mdns_services(self):
return [
('Books in calibre', '_stanza._tcp', self.port,
{'path':self.prefix+'/stanza'}),
('Books in calibre', '_calibre._tcp', self.port,
{'path':self.prefix+'/opds'}),
]
def start(self):
zeroconf_ip_address = verify_ipV4_address(self.ip_address)
try:
for s in self.mdns_services:
publish_zeroconf(*s, use_ip_address=zeroconf_ip_address)
except:
import traceback
cherrypy.log.error('Failed to start BonJour:')
cherrypy.log.error(traceback.format_exc())
start.priority = 90
def stop(self):
try:
for s in self.mdns_services:
unpublish_zeroconf(*s)
except:
import traceback
cherrypy.log.error('Failed to stop BonJour:')
cherrypy.log.error(traceback.format_exc())
stop.priority = 10
cherrypy.engine.bonjour = BonJour(cherrypy.engine)
# }}}
class LibraryServer(ContentServer, MobileServer, XMLServer, OPDSServer, Cache,
BrowseServer, AjaxServer):
server_name = __appname__ + '/' + __version__
def __init__(self, db, opts, embedded=False, show_tracebacks=True,
wsgi=False):
self.is_wsgi = bool(wsgi)
self.opts = opts
self.embedded = embedded
self.state_callback = None
self.start_failure_callback = None
try:
self.max_cover_width, self.max_cover_height = \
map(int, self.opts.max_cover.split('x'))
except:
self.max_cover_width = 1200
self.max_cover_height = 1600
path = P('content_server')
self.build_time = fromtimestamp(os.stat(path).st_mtime)
self.default_cover = open(P('content_server/default_cover.jpg'), 'rb').read()
if not opts.url_prefix:
opts.url_prefix = ''
cherrypy.engine.bonjour.ip_address = listen_on
cherrypy.engine.bonjour.port = opts.port
cherrypy.engine.bonjour.prefix = opts.url_prefix
Cache.__init__(self)
self.set_database(db)
st = 0.1 if opts.develop else 1
cherrypy.config.update({
'log.screen' : opts.develop,
'engine.autoreload.on' : getattr(opts,
'auto_reload', False),
'tools.log_headers.on' : opts.develop,
'tools.encode.encoding' : 'UTF-8',
'checker.on' : opts.develop,
'request.show_tracebacks': show_tracebacks,
'server.socket_host' : listen_on,
'server.socket_port' : opts.port,
'server.socket_timeout' : opts.timeout, # seconds
'server.thread_pool' : opts.thread_pool, # number of threads
'server.shutdown_timeout': st, # minutes
})
if embedded or wsgi:
cherrypy.config.update({'engine.SIGHUP' : None,
'engine.SIGTERM' : None,})
self.config = {}
self.is_running = False
self.exception = None
auth_controller = None
self.users_dict = {}
# self.config['/'] = {
# 'tools.sessions.on' : True,
# 'tools.sessions.timeout': 60, # Session times out after 60 minutes
# }
if not wsgi:
self.setup_loggers()
cherrypy.engine.bonjour.subscribe()
self.config['global'] = {
'tools.gzip.on' : True,
'tools.gzip.mime_types': ['text/html', 'text/plain',
'text/xml', 'text/javascript', 'text/css'],
}
if opts.username and opts.password:
self.users_dict[opts.username.strip()] = opts.password.strip()
auth_controller = AuthController('Your calibre library',
self.users_dict)
self.__dispatcher__ = DispatchController(self.opts.url_prefix,
wsgi=wsgi, auth_controller=auth_controller)
for x in self.__class__.__bases__:
if hasattr(x, 'add_routes'):
x.__init__(self)
x.add_routes(self, self.__dispatcher__)
root_conf = self.config.get('/', {})
root_conf['request.dispatch'] = self.__dispatcher__.dispatcher
self.config['/'] = root_conf
def set_database(self, db):
self.db = db
virt_libs = db.prefs.get('virtual_libraries', {})
sr = getattr(self.opts, 'restriction', None)
if sr:
if sr in virt_libs:
sr = virt_libs[sr]
elif sr not in self.db.saved_search_names():
prints('WARNING: Content server: search restriction ',
sr, ' does not exist')
sr = ''
else:
sr = 'search:"%s"'%sr
else:
sr = db.prefs.get('cs_virtual_lib_on_startup', '')
if sr:
if sr not in virt_libs:
prints('WARNING: Content server: virtual library ',
sr, ' does not exist')
sr = ''
else:
sr = virt_libs[sr]
self.search_restriction = sr
self.reset_caches()
def graceful(self):
cherrypy.engine.graceful()
def setup_loggers(self):
access_file = log_access_file
error_file = log_error_file
log = cherrypy.log
maxBytes = getattr(log, "rot_maxBytes", 10000000)
backupCount = getattr(log, "rot_backupCount", 1000)
# Make a new RotatingFileHandler for the error log.
h = RotatingFileHandler(error_file, 'a', maxBytes, backupCount)
h.setLevel(logging.DEBUG)
h.setFormatter(cherrypy._cplogging.logfmt)
log.error_log.addHandler(h)
# Make a new RotatingFileHandler for the access log.
h = RotatingFileHandler(access_file, 'a', maxBytes, backupCount)
h.setLevel(logging.DEBUG)
h.setFormatter(cherrypy._cplogging.logfmt)
log.access_log.addHandler(h)
def start_cherrypy(self):
try:
cherrypy.engine.start()
except:
ip = get_external_ip()
if not ip or ip.startswith('127.'):
raise
cherrypy.log('Trying to bind to single interface: '+ip)
# Change the host we listen on
cherrypy.config.update({'server.socket_host' : ip})
# This ensures that the change is actually applied
cherrypy.server.socket_host = ip
cherrypy.server.httpserver = cherrypy.server.instance = None
cherrypy.engine.start()
def start(self):
self.is_running = False
self.exception = None
cherrypy.tree.mount(root=None, config=self.config)
try:
self.start_cherrypy()
except Exception as e:
self.exception = e
import traceback
traceback.print_exc()
if callable(self.start_failure_callback):
try:
self.start_failure_callback(as_unicode(e))
except:
pass
return
try:
self.is_running = True
self.notify_listener()
cherrypy.engine.block()
except Exception as e:
import traceback
traceback.print_exc()
self.exception = e
finally:
self.is_running = False
self.notify_listener()
def notify_listener(self):
try:
if callable(self.state_callback):
self.state_callback(self.is_running)
except:
pass
def exit(self):
try:
cherrypy.engine.exit()
finally:
cherrypy.server.httpserver = None
self.is_running = False
self.notify_listener()
def threaded_exit(self):
from threading import Thread
t = Thread(target=self.exit)
t.daemon = True
t.start()
def search_for_books(self, query):
return self.db.search_getting_ids(
(query or '').strip(), self.search_restriction,
sort_results=False, use_virtual_library=False)

View File

@ -1,983 +0,0 @@
#!/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 operator, os, json, re, time
from binascii import hexlify, unhexlify
from collections import OrderedDict
import cherrypy
from calibre.constants import filesystem_encoding, config_dir
from calibre import (isbytestring, force_unicode, prepare_string_for_xml, sanitize_file_name2)
from calibre.utils.filenames import ascii_filename
from calibre.utils.config import prefs, JSONConfig
from calibre.utils.icu import sort_key
from calibre.utils.img import scale_image
from calibre.library.comments import comments_to_html
from calibre.library.server import custom_fields_to_display
from calibre.library.field_metadata import category_icon_map
from calibre.library.server.utils import quote, unquote
from calibre.db.categories import Tag
from calibre.ebooks.metadata.sources.identify import urls_from_identifiers
def xml(*args, **kwargs):
ans = prepare_string_for_xml(*args, **kwargs)
return ans.replace('&apos;', '&#39;')
def render_book_list(ids, prefix, suffix=''): # {{{
pages = []
num = len(ids)
pos = 0
delta = 25
while ids:
page = list(ids[:delta])
pages.append((page, pos))
ids = ids[delta:]
pos += len(page)
page_template = u'''\
<div class="page" id="page{0}">
<div class="load_data" title="{1}">
<span class="url" title="{prefix}/browse/booklist_page"></span>
<span class="start" title="{start}"></span>
<span class="end" title="{end}"></span>
</div>
<div class="loading"><img src="{prefix}/static/loading.gif" /> {2}</div>
<div class="loaded"></div>
</div>
'''
pagelist_template = u'''\
<div class="pagelist">
<ul>
{pages}
</ul>
</div>
'''
rpages, lpages = [], []
for i, x in enumerate(pages):
pg, pos = x
ld = xml(json.dumps(pg), True)
start, end = pos+1, pos+len(pg)
rpages.append(page_template.format(i, ld,
xml(_('Loading, please wait')) + '&hellip;',
start=start, end=end, prefix=prefix))
lpages.append(' '*20 + (u'<li><a href="#" title="Books {start} to {end}"'
' onclick="gp_internal(\'{id}\'); return false;"> '
'{start}&nbsp;to&nbsp;{end}</a></li>').format(start=start, end=end,
id='page%d'%i))
rpages = u'\n\n'.join(rpages)
lpages = u'\n'.join(lpages)
pagelist = pagelist_template.format(pages=lpages)
templ = u'''\
<h3>{0} {suffix}</h3>
<div id="booklist">
<div id="pagelist" title="{goto}">{pagelist}</div>
<div class="listnav topnav">
{navbar}
</div>
{pages}
<div class="listnav bottomnav">
{navbar}
</div>
</div>
'''
gp_start = gp_end = ''
if len(pages) > 1:
gp_start = '<a href="#" onclick="goto_page(); return false;" title="%s">' % \
(_('Go to') + '&hellip;')
gp_end = '</a>'
navbar = u'''\
<div class="navleft">
<a href="#" onclick="first_page(); return false;">{first}</a>
<a href="#" onclick="previous_page(); return false;">{previous}</a>
</div>
<div class="navmiddle">
{gp_start}
<span class="start">0</span> to <span class="end">0</span>
{gp_end}of {num}
</div>
<div class="navright">
<a href="#" onclick="next_page(); return false;">{next}</a>
<a href="#" onclick="last_page(); return false;">{last}</a>
</div>
'''.format(first=_('First'), last=_('Last'), previous=_('Previous'),
next=_('Next'), num=num, gp_start=gp_start, gp_end=gp_end)
return templ.format(_('Browsing %d books')%num, suffix=suffix,
pages=rpages, navbar=navbar, pagelist=pagelist,
goto=xml(_('Go to'), True) + '&hellip;')
# }}}
def utf8(x): # {{{
if isinstance(x, unicode):
x = x.encode('utf-8')
return x
# }}}
def render_rating(rating, url_prefix, container='span', prefix=None): # {{{
if rating < 0.1:
return '', ''
added = 0
if prefix is None:
prefix = _('Average rating')
rstring = xml(_('%(prefix)s: %(rating).1f stars')%dict(
prefix=prefix, rating=rating if rating else 0.0),
True)
ans = ['<%s class="rating">' % (container)]
for i in range(5):
n = rating - added
x = 'half'
if n <= 0.1:
x = 'off'
elif n >= 0.9:
x = 'on'
ans.append(
u'<img alt="{0}" title="{0}" src="{2}/static/star-{1}.png" />'.format(
rstring, x, url_prefix))
added += 1
ans.append('</%s>'%container)
return u''.join(ans), rstring
# }}}
def get_category_items(category, items, datatype, prefix): # {{{
def item(i):
templ = (u'<div title="{4}" class="category-item">'
'<div class="category-name">'
'<a href="{5}{3}" title="{4}">{0}</a></div>'
'<div>{1}</div>'
'<div>{2}</div></div>')
rating, rstring = render_rating(i.avg_rating, prefix)
orig_name = i.sort if i.use_sort_as_name else i.name
name = xml(orig_name)
if datatype == 'rating':
name = xml(_('%d stars')%int(i.avg_rating))
id_ = i.id
if id_ is None:
id_ = hexlify(force_unicode(orig_name).encode('utf-8'))
id_ = xml(str(id_))
desc = ''
if i.count > 0:
desc += '[' + _('%d books')%i.count + ']'
q = i.category
if not q:
q = category
href = '/browse/matches/%s/%s'%(quote(q), quote(id_))
return templ.format(xml(name), rating,
xml(desc), xml(href, True), rstring, prefix)
items = list(map(item, items))
return '\n'.join(['<div class="category-container">'] + items + ['</div>'])
# }}}
class Endpoint(object): # {{{
'Manage encoding, mime-type, last modified, cookies, etc.'
def __init__(self, mimetype='text/html; charset=utf-8', sort_type='category'):
self.mimetype = mimetype
self.sort_type = sort_type
self.sort_kwarg = sort_type + '_sort'
self.sort_cookie_name = 'calibre_browse_server_sort_'+self.sort_type
def __call__(eself, func):
def do(self, *args, **kwargs):
if 'json' not in eself.mimetype:
sort_val = None
cookie = cherrypy.request.cookie
if eself.sort_cookie_name in cookie:
sort_val = cookie[eself.sort_cookie_name].value
kwargs[eself.sort_kwarg] = sort_val
# Remove AJAX caching disabling jquery workaround arg
kwargs.pop('_', None)
ans = func(self, *args, **kwargs)
cherrypy.response.headers['Content-Type'] = eself.mimetype
updated = self.db.last_modified()
cherrypy.response.headers['Last-Modified'] = \
self.last_modified(max(updated, self.build_time))
ans = utf8(ans)
return ans
do.__name__ = func.__name__
return do
# }}}
class BrowseServer(object):
def add_routes(self, connect):
base_href = '/browse'
connect('browse', base_href, self.browse_catalog)
connect('browse_catalog', base_href+'/category/{category}',
self.browse_catalog)
connect('browse_category_group',
base_href+'/category_group/{category}/{group}',
self.browse_category_group)
connect('browse_matches',
base_href+'/matches/{category}/{cid}',
self.browse_matches)
connect('browse_booklist_page',
base_href+'/booklist_page',
self.browse_booklist_page)
connect('browse_search', base_href+'/search',
self.browse_search)
connect('browse_details', base_href+'/details/{id}',
self.browse_details)
connect('browse_book', base_href+'/book/{id}',
self.browse_book)
connect('browse_random', base_href+'/random',
self.browse_random)
connect('browse_category_icon', base_href+'/icon/{name}',
self.browse_icon)
self.icon_map = JSONConfig('gui').get('tags_browser_category_icons', {})
# Templates {{{
def browse_template(self, sort, category=True, initial_search=''):
if not hasattr(self, '__browse_template__') or \
self.opts.develop:
self.__browse_template__ = \
P('content_server/browse/browse.html', data=True).decode('utf-8')
ans = self.__browse_template__
scn = 'calibre_browse_server_sort_'
if category:
sort_opts = [('rating', _('Average rating')), ('name',
_('Name')), ('popularity', _('Popularity'))]
scn += 'category'
else:
scn += 'list'
fm = self.db.field_metadata
sort_opts, added = [], set([])
displayed_custom_fields = custom_fields_to_display(self.db)
for x in fm.sortable_field_keys():
if x in ('ondevice', 'formats', 'sort'):
continue
if fm.is_ignorable_field(x) and x not in displayed_custom_fields:
continue
if x == 'comments' or fm[x]['datatype'] == 'comments':
continue
n = fm[x]['name']
if n not in added:
added.add(n)
sort_opts.append((x, n))
ans = ans.replace('{sort_select_label}', xml(_('Sort by')+':'))
ans = ans.replace('{sort_cookie_name}', scn)
ans = ans.replace('{prefix}', self.opts.url_prefix)
ans = ans.replace('{library}', _('library'))
ans = ans.replace('{home}', _('home'))
ans = ans.replace('{Search}', _('Search'))
opts = ['<option %svalue="%s">%s</option>' % (
'selected="selected" ' if k==sort else '',
xml(k), xml(nl), ) for k, nl in
sorted(sort_opts, key=lambda x: sort_key(operator.itemgetter(1)(x))) if k and nl]
ans = ans.replace('{sort_select_options}', ('\n'+' '*20).join(opts))
lp = self.db.library_path
if isbytestring(lp):
lp = force_unicode(lp, filesystem_encoding)
ans = ans.replace('{library_name}', xml(os.path.basename(lp)))
ans = ans.replace('{library_path}', xml(lp, True))
ans = ans.replace('{initial_search}', xml(initial_search, attribute=True))
return ans
@property
def browse_summary_template(self):
if not hasattr(self, '__browse_summary_template__') or \
self.opts.develop:
self.__browse_summary_template__ = \
P('content_server/browse/summary.html', data=True).decode('utf-8')
return self.__browse_summary_template__.replace('{prefix}',
self.opts.url_prefix)
@property
def browse_details_template(self):
if not hasattr(self, '__browse_details_template__') or \
self.opts.develop:
self.__browse_details_template__ = \
P('content_server/browse/details.html', data=True).decode('utf-8')
return self.__browse_details_template__.replace('{prefix}',
self.opts.url_prefix)
# }}}
# Catalogs {{{
def browse_icon(self, name='blank.png'):
cherrypy.response.headers['Content-Type'] = 'image/png'
cherrypy.response.headers['Last-Modified'] = self.last_modified(self.build_time)
if not hasattr(self, '__browse_icon_cache__'):
self.__browse_icon_cache__ = {}
if name not in self.__browse_icon_cache__:
if name.startswith('_'):
name = sanitize_file_name2(name[1:])
try:
with open(os.path.join(config_dir, 'tb_icons', name), 'rb') as f:
data = f.read()
except:
raise cherrypy.HTTPError(404, 'no icon named: %r'%name)
else:
try:
data = I(name, data=True)
except:
raise cherrypy.HTTPError(404, 'no icon named: %r'%name)
self.__browse_icon_cache__[name] = scale_image(data, 48, 48, as_png=True)[-1]
return self.__browse_icon_cache__[name]
def browse_toplevel(self):
categories = self.categories_cache()
category_meta = self.db.field_metadata
cats = [
(_('Newest'), 'newest', 'forward.png'),
(_('All books'), 'allbooks', 'book.png'),
(_('Random book'), 'randombook', 'random.png'),
]
virt_libs = self.db.prefs.get('virtual_libraries', {})
if virt_libs:
cats.append((_('Virtual Libs.'), 'virt_libs', 'lt.png'))
def getter(x):
try:
return category_meta[x]['name'].lower()
except KeyError:
return x
displayed_custom_fields = custom_fields_to_display(self.db)
uc_displayed = set()
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
if self.db.field_metadata.is_ignorable_field(category) and \
category not in displayed_custom_fields:
continue
# get the icon files
main_cat = (category.partition('.')[0]) if hasattr(category,
'partition') else category
if main_cat in self.icon_map:
icon = '_'+quote(self.icon_map[main_cat])
elif category in category_icon_map:
icon = category_icon_map[category]
elif meta['is_custom']:
icon = category_icon_map['custom:']
elif meta['kind'] == 'user':
icon = category_icon_map['user:']
else:
icon = 'blank.png'
if meta['kind'] == 'user':
dot = category.find('.')
if dot > 0:
cat = category[:dot]
if cat not in uc_displayed:
cats.append((meta['name'][:dot-1], cat, icon))
uc_displayed.add(cat)
else:
cats.append((meta['name'], category, icon))
uc_displayed.add(category)
else:
cats.append((meta['name'], category, icon))
cats = [(u'<li><a title="{2} {0}" href="{3}/browse/category/{1}">&nbsp;</a>'
u'<img src="{3}{src}" alt="{0}" />'
u'<span class="label">{0}</span>'
u'</li>')
.format(xml(x, True), xml(quote(y)), xml(_('Browse books by')),
self.opts.url_prefix, src='/browse/icon/'+z)
for x, y, z in cats]
main = u'<div class="toplevel"><h3>{0}</h3><ul>{1}</ul></div>'\
.format(_('Choose a category to browse by:'), u'\n\n'.join(cats))
return self.browse_template('name').format(title='',
script='toplevel();', main=main)
def browse_sort_categories(self, items, sort):
if sort not in ('rating', 'name', 'popularity'):
sort = 'name'
items.sort(key=lambda x: sort_key(getattr(x, 'sort', x.name)))
if sort == 'popularity':
items.sort(key=operator.attrgetter('count'), reverse=True)
elif sort == 'rating':
items.sort(key=operator.attrgetter('avg_rating'), reverse=True)
return sort
def browse_category(self, category, sort):
categories = self.categories_cache()
categories['virt_libs'] = {}
if category not in categories:
raise cherrypy.HTTPError(404, 'category not found')
category_meta = self.db.field_metadata
category_name = _('Virtual Libraries') if category == 'virt_libs' else category_meta[category]['name']
datatype = 'text' if category == 'virt_libs' else category_meta[category]['datatype']
# See if we have any sub-categories to display. As we find them, add
# them to the displayed set to avoid showing the same item twice
uc_displayed = set()
cats = []
for ucat in sorted(categories.keys(), key=sort_key):
if len(categories[ucat]) == 0:
continue
if category == 'formats':
continue
meta = category_meta.get(ucat, None)
if meta is None:
continue
if meta['kind'] != 'user':
continue
cat_len = len(category)
if not (len(ucat) > cat_len and ucat.startswith(category+'.')):
continue
if ucat in self.icon_map:
icon = '_'+quote(self.icon_map[ucat])
else:
icon = category_icon_map['user:']
# we have a subcategory. Find any further dots (further subcats)
cat_len += 1
cat = ucat[cat_len:]
dot = cat.find('.')
if dot > 0:
# More subcats
cat = cat[:dot]
if cat not in uc_displayed:
cats.append((cat, ucat[:cat_len+dot], icon))
uc_displayed.add(cat)
else:
# This is the end of the chain
cats.append((cat, ucat, icon))
uc_displayed.add(cat)
cats = u'\n\n'.join(
[(u'<li><a title="{2} {0}" href="{3}/browse/category/{1}">&nbsp;</a>'
u'<img src="{3}{src}" alt="{0}" />'
u'<span class="label">{0}</span>'
u'</li>')
.format(xml(x, True), xml(quote(y)), xml(_('Browse books by')),
self.opts.url_prefix, src='/browse/icon/'+z)
for x, y, z in cats])
if cats:
cats = (u'\n<div class="toplevel">\n'
'{0}</div>').format(cats)
script = 'toplevel();'
else:
script = 'true'
# Now do the category items
vls = self.db.prefs.get('virtual_libraries', {})
categories['virt_libs'] = sorted([Tag(k) for k, v in vls.iteritems()], key=lambda x:sort_key(x.name))
items = categories[category]
sort = self.browse_sort_categories(items, sort)
if not cats and len(items) == 1:
# Only one item in category, go directly to book list
html = get_category_items(category, items,
datatype, self.opts.url_prefix)
href = re.search(r'<a href="([^"]+)"', html)
if href is not None:
# cherrypy does not auto unquote params when using
# InternalRedirect
raise cherrypy.InternalRedirect(unquote(href.group(1)))
if len(items) <= self.opts.max_opds_ungrouped_items:
script = 'false'
items = get_category_items(category, items,
datatype, self.opts.url_prefix)
else:
getter = lambda x: unicode(getattr(x, 'sort', None) or x.name)
starts = set([])
for x in items:
val = getter(x)
if not val:
val = u'A'
starts.add(val[0].upper())
category_groups = OrderedDict()
for x in sorted(starts):
category_groups[x] = len([y for y in items if
getter(y).upper().startswith(x)])
items = [(u'<h3 title="{0}"><a class="load_href" title="{0}"'
u' href="{4}{3}"><strong>{0}</strong> [{2}]</a></h3><div>'
u'<div class="loaded" style="display:none"></div>'
u'<div class="loading"><img alt="{1}" src="{4}/static/loading.gif" /><em>{1}</em></div>'
u'</div>').format(
xml(s, True),
xml(_('Loading, please wait'))+'&hellip;',
unicode(c),
xml(u'/browse/category_group/%s/%s'%(
hexlify(category.encode('utf-8')),
hexlify(s.encode('utf-8'))), True),
self.opts.url_prefix)
for s, c in category_groups.items()]
items = '\n\n'.join(items)
items = u'<div id="groups">\n{0}</div>'.format(items)
if cats:
script = 'toplevel();category(%s);'%script
else:
script = 'category(%s);'%script
main = u'''
<div class="category">
<h3>{0}</h3>
<a class="navlink" href="{3}/browse"
title="{2}">{2}&nbsp;&uarr;</a>
{1}
</div>
'''.format(
xml(_('Browsing by')+': ' + category_name), cats + items,
xml(_('Up'), True), self.opts.url_prefix)
return self.browse_template(sort).format(title=category_name,
script=script, main=main)
@Endpoint(mimetype='application/json; charset=utf-8')
def browse_category_group(self, category=None, group=None, sort=None):
if sort == 'null':
sort = None
if sort not in ('rating', 'name', 'popularity'):
sort = 'name'
try:
category = unhexlify(category)
if isbytestring(category):
category = category.decode('utf-8')
except:
raise cherrypy.HTTPError(404, 'invalid category')
categories = self.categories_cache()
if category not in categories:
raise cherrypy.HTTPError(404, 'category not found')
category_meta = self.db.field_metadata
try:
datatype = category_meta[category]['datatype']
except KeyError:
datatype = 'text'
try:
group = unhexlify(group)
if isbytestring(group):
group = group.decode('utf-8')
except:
raise cherrypy.HTTPError(404, 'invalid group')
items = categories[category]
entries = []
getter = lambda x: unicode(getattr(x, 'sort', None) or x.name)
for x in items:
val = getter(x)
if not val:
val = u'A'
if val.upper().startswith(group):
entries.append(x)
sort = self.browse_sort_categories(entries, sort)
entries = get_category_items(category, entries,
datatype, self.opts.url_prefix)
return json.dumps(entries, ensure_ascii=True)
@Endpoint()
def browse_catalog(self, category=None, category_sort=None):
'Entry point for top-level, categories and sub-categories'
prefix = '' if self.is_wsgi else self.opts.url_prefix
if category is None:
ans = self.browse_toplevel()
# The following are fake categories used for the top-level view
elif category == 'newest':
raise cherrypy.InternalRedirect(prefix +
'/browse/matches/newest/dummy')
elif category == 'allbooks':
raise cherrypy.InternalRedirect(prefix +
'/browse/matches/allbooks/dummy')
elif category == 'randombook':
raise cherrypy.InternalRedirect(prefix +
'/browse/random')
else:
ans = self.browse_category(category, category_sort)
return ans
# }}}
# Book Lists {{{
def browse_sort_book_list(self, items, sort):
fm = self.db.field_metadata
keys = frozenset(fm.sortable_field_keys())
if sort not in keys:
sort = 'title'
self.sort(items, 'title', True)
if sort != 'title':
ascending = fm[sort]['datatype'] not in ('rating', 'datetime',
'series')
self.sort(items, sort, ascending)
return sort
@Endpoint(sort_type='list')
def browse_matches(self, category=None, cid=None, list_sort=None):
if list_sort:
list_sort = unquote(list_sort)
if not cid:
raise cherrypy.HTTPError(404, 'invalid category id: %r'%cid)
categories = self.categories_cache()
if category not in categories and \
category not in ('newest', 'allbooks', 'virt_libs'):
raise cherrypy.HTTPError(404, 'category not found')
fm = self.db.field_metadata
try:
category_name = fm[category]['name']
dt = fm[category]['datatype']
except:
if category not in ('newest', 'allbooks', 'virt_libs'):
raise
category_name = {
'newest' : _('Newest'),
'allbooks' : _('All books'),
'virt_libs': _('Virtual Libraries'),
}[category]
dt = None
hide_sort = 'true' if dt == 'series' else 'false'
if category == 'search':
which = unhexlify(cid).decode('utf-8')
try:
ids = self.search_cache('search:"%s"'%which)
except:
raise cherrypy.HTTPError(404, 'Search: %r not understood'%which)
else:
all_ids = self.search_cache('')
if category == 'newest':
ids = all_ids
hide_sort = 'true'
elif category == 'allbooks':
ids = all_ids
elif category == 'virt_libs':
which = unhexlify(cid).decode('utf-8')
vls = self.db.prefs.get('virtual_libraries', {})
ids = self.search_cache(vls[which])
category_name = _('virtual library: ') + xml(which)
if not ids:
msg = _('The virtual library <b>%s</b> has no books.') % prepare_string_for_xml(which)
if self.search_restriction:
msg += ' ' + _(
'This is probably because you have applied a virtual library'
' to the content server in Preferences->Sharing over the net.'
' This virtual library is applied globally and combined with'
' the current virtual library.')
return self.browse_template('name').format(title='',
script='', main='<p>%s</p>'%msg)
else:
if fm.get(category, {'datatype':None})['datatype'] == 'composite':
cid = cid.decode('utf-8')
q = category
if q == 'news':
q = 'tags'
ids = self.db.get_books_for_category(q, cid)
ids = [x for x in ids if x in all_ids]
items = [self.db.data.tablerow_for_id(x) for x in ids]
if category == 'newest':
list_sort = 'timestamp'
if dt == 'series':
list_sort = category
sort = self.browse_sort_book_list(items, list_sort)
ids = [x[0] for x in items]
html = render_book_list(ids, self.opts.url_prefix,
suffix=_('in') + ' ' + category_name)
return self.browse_template(sort, category=False).format(
title=_('Books in') + " " +category_name,
script='booklist(%s);'%hide_sort, main=html)
def browse_get_book_args(self, mi, id_, add_category_links=False):
fmts = self.db.formats(id_, index_is_id=True)
if not fmts:
fmts = ''
fmts = [x.lower() for x in fmts.split(',') if x]
pf = prefs['output_format'].lower()
try:
fmt = pf if pf in fmts else fmts[0]
except:
fmt = None
args = {'id':id_, 'mi':mi,
}
ccache = self.categories_cache() if add_category_links else {}
ftitle = fauthors = ''
for key in mi.all_field_keys():
val = mi.format_field(key)[1]
if not val:
val = ''
if key == 'title':
ftitle = xml(val, True)
elif key == 'authors':
fauthors = xml(val, True)
if add_category_links:
added_key = False
fm = mi.metadata_for_field(key)
if val and fm and fm['is_category'] and not fm['is_csp'] and\
key != 'formats' and fm['datatype'] not in ['rating']:
categories = mi.get(key)
if isinstance(categories, basestring):
categories = [categories]
dbtags = []
for category in categories:
dbtag = None
for tag in ccache[key]:
if tag.name == category:
dbtag = tag
break
dbtags.append(dbtag)
if None not in dbtags:
vals = []
for tag in dbtags:
tval = ('<a title="Browse books by {3}: {0}"'
' href="{1}" class="details_category_link">{2}</a>')
href='%s/browse/matches/%s/%s' % \
(self.opts.url_prefix, quote(tag.category), quote(str(tag.id)))
vals.append(tval.format(xml(tag.name, True),
xml(href, True),
xml(val if len(dbtags) == 1 else tag.name),
xml(key, True)))
join = ' &amp; ' if key == 'authors' or \
(fm['is_custom'] and
fm['display'].get('is_names', False)) \
else ', '
args[key] = join.join(vals)
added_key = True
if not added_key:
args[key] = xml(val, True)
else:
args[key] = xml(val, True)
fname = quote(ascii_filename(ftitle) + ' - ' +
ascii_filename(fauthors))
return args, fmt, fmts, fname
@Endpoint(mimetype='application/json; charset=utf-8')
def browse_booklist_page(self, ids=None, sort=None):
if sort == 'null':
sort = None
if ids is None:
ids = json.dumps('[]')
try:
ids = json.loads(ids)
except:
raise cherrypy.HTTPError(404, 'invalid ids')
summs = []
for id_ in ids:
try:
id_ = int(id_)
mi = self.db.get_metadata(id_, index_is_id=True)
except:
continue
args, fmt, fmts, fname = self.browse_get_book_args(mi, id_)
args['other_formats'] = ''
args['fmt'] = fmt
if fmts and fmt:
other_fmts = [x for x in fmts if x.lower() != fmt.lower()]
if other_fmts:
ofmts = [u'<a href="{4}/get/{0}/{1}_{2}.{0}" title="{3}">{3}</a>'
.format(f, fname, id_, f.upper(),
self.opts.url_prefix) for f in
other_fmts]
ofmts = ', '.join(ofmts)
args['other_formats'] = u'<strong>%s: </strong>' % \
_('Other formats') + ofmts
args['details_href'] = self.opts.url_prefix + '/browse/details/'+str(id_)
if fmt:
href = self.opts.url_prefix + '/get/%s/%s_%d.%s'%(
fmt, fname, id_, fmt)
rt = xml(_('Read %(title)s in the %(fmt)s format')%
{'title':args['title'], 'fmt':fmt.upper()}, True)
args['get_button'] = \
'<a href="%s" class="read" title="%s">%s</a>' % \
(xml(href, True), rt, xml(_('Get')))
args['get_url'] = xml(href, True)
else:
args['get_button'] = ''
args['get_url'] = 'javascript:alert(\'%s\')' % xml(_(
'This book has no available formats to view'), True)
args['comments'] = comments_to_html(mi.comments)
args['stars'] = ''
if mi.rating:
args['stars'] = render_rating(mi.rating/2.0,
self.opts.url_prefix, prefix=_('Rating'))[0]
if args['tags']:
args['tags'] = u'<strong>%s: </strong>'%xml(_('Tags')) + \
args['tags']
if args['series']:
args['series'] = args['series']
args['details'] = xml(_('Details'), True)
args['details_tt'] = xml(_('Show book details'), True)
args['permalink'] = xml(_('Permalink'), True)
args['permalink_tt'] = xml(_('A permanent link to this book'), True)
summs.append(self.browse_summary_template.format(**args))
raw = json.dumps('\n'.join(summs), ensure_ascii=True)
return raw
def browse_render_details(self, id_, add_random_button=False, add_title=False):
try:
mi = self.db.get_metadata(id_, index_is_id=True)
except:
return _('This book has been deleted')
else:
args, fmt, fmts, fname = self.browse_get_book_args(mi, id_,
add_category_links=True)
args['fmt'] = fmt
if fmt:
args['get_url'] = xml(self.opts.url_prefix + '/get/%s/%s_%d.%s'%(
fmt, fname, id_, fmt), True)
else:
args['get_url'] = 'javascript:alert(\'%s\')' % xml(_(
'This book has no available formats to view'), True)
args['formats'] = ''
if fmts:
ofmts = [u'<a href="{4}/get/{0}/{1}_{2}.{0}" title="{3}">{3}</a>'
.format(xfmt, fname, id_, xfmt.upper(),
self.opts.url_prefix) for xfmt in fmts]
ofmts = ', '.join(ofmts)
args['formats'] = ofmts
fields, comments = [], []
displayed_custom_fields = custom_fields_to_display(self.db)
for field, m in list(mi.get_all_standard_metadata(False).items()) + \
list(mi.get_all_user_metadata(False).items()):
if self.db.field_metadata.is_ignorable_field(field) and \
field not in displayed_custom_fields:
continue
if m['datatype'] == 'comments' or field == 'comments' or (
m['datatype'] == 'composite' and
m['display'].get('contains_html', False)):
val = mi.get(field, '')
if val and val.strip():
comments.append((m['name'], comments_to_html(val)))
continue
if field in ('title', 'formats') or not args.get(field, False) \
or not m['name']:
continue
if field == 'identifiers':
urls = urls_from_identifiers(mi.get(field, {}))
links = [u'<a class="details_category_link" target="_new" href="%s" title="%s:%s">%s</a>' % (url, id_typ, id_val, name)
for name, id_typ, id_val, url in urls]
links = u', '.join(links)
if links:
fields.append((field, m['name'], u'<strong>%s: </strong>%s'%(
_('Ids'), links)))
continue
if m['datatype'] == 'rating':
r = u'<strong>%s: </strong>'%xml(m['name']) + \
render_rating(mi.get(field)/2.0, self.opts.url_prefix,
prefix=m['name'])[0]
else:
r = u'<strong>%s: </strong>'%xml(m['name']) + \
args[field]
fields.append((field, m['name'], r))
def fsort(x):
num = {'authors':0, 'series':1, 'tags':2}.get(x[0], 100)
return (num, sort_key(x[-1]))
fields.sort(key=fsort)
if add_title:
fields.insert(0, ('title', 'Title', u'<strong>%s: </strong>%s' % (xml(_('Title')), xml(mi.title))))
fields = [u'<div class="field">{0}</div>'.format(f[-1]) for f in
fields]
fields = u'<div class="fields">%s</div>'%('\n\n'.join(fields))
comments.sort(key=lambda x: x[0].lower())
comments = [(u'<div class="field"><strong>%s: </strong>'
u'<div class="comment">%s</div></div>') % (xml(c[0]),
c[1]) for c in comments]
comments = u'<div class="comments">%s</div>'%('\n\n'.join(comments))
random = ''
if add_random_button:
href = '%s/browse/random?v=%s'%(
self.opts.url_prefix, time.time())
random = '<a href="%s" id="random_button" title="%s">%s</a>' % (
xml(href, True), xml(_('Choose another random book'), True),
xml(_('Another random book')))
return self.browse_details_template.format(
id=id_, title=xml(mi.title, True), fields=fields,
get_url=args['get_url'], fmt=args['fmt'],
formats=args['formats'], comments=comments, random=random)
@Endpoint(mimetype='application/json; charset=utf-8')
def browse_details(self, id=None):
try:
id_ = int(id)
except:
raise cherrypy.HTTPError(404, 'invalid id: %r'%id)
ans = self.browse_render_details(id_)
return json.dumps(ans, ensure_ascii=True)
@Endpoint()
def browse_random(self, *args, **kwargs):
import random
try:
book_id = random.choice(self.search_for_books(''))
except IndexError:
raise cherrypy.HTTPError(404, 'This library has no books')
ans = self.browse_render_details(book_id, add_random_button=True, add_title=True)
return self.browse_template('').format(
title=prepare_string_for_xml(self.db.title(book_id, index_is_id=True)), script='book();', main=ans)
@Endpoint()
def browse_book(self, id=None, category_sort=None):
try:
id_ = int(id)
except:
raise cherrypy.HTTPError(404, 'invalid id: %r'%id)
ans = self.browse_render_details(id_, add_title=True)
return self.browse_template('').format(
title=prepare_string_for_xml(self.db.title(id_, index_is_id=True)), script='book();', main=ans)
# }}}
# Search {{{
@Endpoint(sort_type='list')
def browse_search(self, query='', list_sort=None):
if isbytestring(query):
query = query.decode('UTF-8')
ids = self.search_for_books(query)
items = [self.db.data.tablerow_for_id(x) for x in ids]
sort = self.browse_sort_book_list(items, list_sort)
ids = [x[0] for x in items]
html = render_book_list(ids, self.opts.url_prefix,
suffix=_('in search')+': '+xml(query))
return self.browse_template(sort, category=False, initial_search=query).format(
title=_('Matching books'),
script='search_result();', main=html)
# }}}

View File

@ -1,47 +0,0 @@
#!/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'
from collections import OrderedDict
from calibre.utils.date import utcnow
class Cache(object):
def __init__(self):
self.reset_caches()
def reset_caches(self):
self._category_cache = OrderedDict()
self._search_cache = OrderedDict()
def search_cache(self, search):
old = self._search_cache.pop(search, None)
if old is None or old[0] <= self.db.last_modified():
matches = self.search_for_books(search) or []
self._search_cache[search] = (utcnow(), frozenset(matches))
if len(self._search_cache) > 50:
self._search_cache.popitem(last=False)
else:
self._search_cache[search] = old
return self._search_cache[search][1]
def categories_cache(self, restrict_to=frozenset([])):
base_restriction = self.search_cache('')
if restrict_to:
restrict_to = frozenset(restrict_to).intersection(base_restriction)
else:
restrict_to = base_restriction
old = self._category_cache.pop(frozenset(restrict_to), None)
if old is None or old[0] <= self.db.last_modified():
categories = self.db.get_categories(ids=restrict_to)
self._category_cache[restrict_to] = (utcnow(), categories)
if len(self._category_cache) > 20:
self._category_cache.popitem(last=False)
else:
self._category_cache[frozenset(restrict_to)] = old
return self._category_cache[restrict_to][1]

View File

@ -1,262 +0,0 @@
#!/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 re, os, posixpath
import cherrypy
from calibre import guess_type
from calibre.utils.date import fromtimestamp, as_utc
from calibre.utils.img import save_cover_data_to, scale_image
from calibre.library.caches import SortKeyGenerator
from calibre.library.save_to_disk import find_plugboard
from calibre.ebooks.metadata import authors_to_string
from calibre.utils.filenames import ascii_filename
from calibre.ebooks.metadata.opf2 import metadata_to_opf
from calibre.utils.config import tweaks
plugboard_content_server_value = 'content_server'
plugboard_content_server_formats = ['epub', 'mobi', 'azw3']
class CSSortKeyGenerator(SortKeyGenerator):
def __init__(self, fields, fm, db_prefs):
SortKeyGenerator.__init__(self, fields, fm, None, db_prefs)
def __call__(self, record):
return self.itervals(record).next()
class ContentServer(object):
'''
Handles actually serving content files/covers/metadata. Also has
a few utility methods.
'''
def add_routes(self, connect):
connect('root', '/', self.index)
connect('old', '/old', self.old)
connect('get', '/get/{what}/{id}', self.get,
conditions=dict(method=["GET", "HEAD"]),
android_workaround=True)
connect('static', '/static/{name:.*?}', self.static,
conditions=dict(method=["GET", "HEAD"]))
connect('favicon', '/favicon.png', self.favicon,
conditions=dict(method=["GET", "HEAD"]))
# Utility methods {{{
def last_modified(self, updated):
'''
Generates a locale independent, english timestamp from a datetime
object
'''
updated = as_utc(updated)
lm = updated.strftime('day, %d month %Y %H:%M:%S GMT')
day ={0:'Sun', 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat'}
lm = lm.replace('day', day[int(updated.strftime('%w'))])
month = {1:'Jan', 2:'Feb', 3:'Mar', 4:'Apr', 5:'May', 6:'Jun', 7:'Jul',
8:'Aug', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'}
return lm.replace('month', month[updated.month])
def sort(self, items, field, order):
field = self.db.data.sanitize_sort_field_name(field)
if field not in self.db.field_metadata.sortable_field_keys():
raise cherrypy.HTTPError(400, '%s is not a valid sort field'%field)
keyg = CSSortKeyGenerator([(field, order)], self.db.field_metadata,
self.db.prefs)
items.sort(key=keyg, reverse=not order)
# }}}
def get(self, what, id):
'Serves files, covers, thumbnails, metadata from the calibre database'
try:
id = int(id)
except ValueError:
id = id.rpartition('.')[0].rpartition('_')[-1]
match = re.search(r'\d+', id)
if not match:
raise cherrypy.HTTPError(404, 'id:%s not an integer'%id)
id = int(match.group())
if not self.db.has_id(id):
raise cherrypy.HTTPError(404, 'id:%d does not exist in database'%id)
if what == 'thumb' or what.startswith('thumb_'):
try:
width, height = map(int, what.split('_')[1:])
except:
width, height = 60, 80
return self.get_cover(id, thumbnail=True, thumb_width=width,
thumb_height=height)
if what == 'cover':
return self.get_cover(id)
if what == 'opf':
return self.get_metadata_as_opf(id)
if what == 'json':
raise cherrypy.InternalRedirect('/ajax/book/%d'%id)
return self.get_format(id, what)
def static(self, name):
'Serves static content'
name = name.lower()
fname = posixpath.basename(name)
try:
cherrypy.response.headers['Content-Type'] = {
'js' : 'text/javascript',
'css' : 'text/css',
'png' : 'image/png',
'gif' : 'image/gif',
'html' : 'text/html',
}[fname.rpartition('.')[-1].lower()]
except KeyError:
raise cherrypy.HTTPError(404, '%r not a valid resource type'%name)
cherrypy.response.headers['Last-Modified'] = self.last_modified(self.build_time)
basedir = os.path.abspath(P('content_server'))
path = os.path.join(basedir, name.replace('/', os.sep))
path = os.path.abspath(path)
if not path.startswith(basedir):
raise cherrypy.HTTPError(403, 'Access to %s is forbidden'%name)
if not os.path.exists(path) or not os.path.isfile(path):
raise cherrypy.HTTPError(404, '%s not found'%name)
if self.opts.develop:
lm = fromtimestamp(os.stat(path).st_mtime)
cherrypy.response.headers['Last-Modified'] = self.last_modified(lm)
with open(path, 'rb') as f:
ans = f.read()
if path.endswith('.css'):
ans = ans.replace('/static/', self.opts.url_prefix + '/static/')
return ans
def favicon(self):
data = I('lt.png', data=True)
cherrypy.response.headers['Content-Type'] = 'image/png'
cherrypy.response.headers['Last-Modified'] = self.last_modified(
self.build_time)
return data
def index(self, **kwargs):
'The / URL'
ua = cherrypy.request.headers.get('User-Agent', '').strip()
want_opds = \
cherrypy.request.headers.get('Stanza-Device-Name', 919) != 919 or \
cherrypy.request.headers.get('Want-OPDS-Catalog', 919) != 919 or \
ua.startswith('Stanza')
want_mobile = self.is_mobile_browser(ua)
if self.opts.develop and not want_mobile:
cherrypy.log('User agent: '+ua)
if want_opds:
return self.opds(version=0)
if want_mobile:
return self.mobile()
return self.browse_catalog()
def old(self, **kwargs):
return self.static('index.html').replace('{prefix}',
self.opts.url_prefix)
# Actually get content from the database {{{
def get_cover(self, id, thumbnail=False, thumb_width=60, thumb_height=80):
try:
cherrypy.response.headers['Content-Type'] = 'image/jpeg'
cherrypy.response.timeout = 3600
cover = self.db.cover(id, index_is_id=True)
if cover is None:
cover = self.default_cover
updated = self.build_time
else:
updated = self.db.cover_last_modified(id, index_is_id=True)
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
if thumbnail:
quality = tweaks['content_server_thumbnail_compression_quality']
if quality < 50:
quality = 50
elif quality > 99:
quality = 99
return scale_image(cover, thumb_width, thumb_height, compression_quality=quality)[-1]
return save_cover_data_to(cover, None, minify_to=(self.max_cover_width, self.max_cover_height))
except Exception as err:
import traceback
cherrypy.log.error('Failed to generate cover:')
cherrypy.log.error(traceback.print_exc())
raise cherrypy.HTTPError(404, 'Failed to generate cover: %r'%err)
def get_metadata_as_opf(self, id_):
cherrypy.response.headers['Content-Type'] = \
'application/oebps-package+xml; charset=UTF-8'
mi = self.db.get_metadata(id_, index_is_id=True)
data = metadata_to_opf(mi)
cherrypy.response.timeout = 3600
cherrypy.response.headers['Last-Modified'] = \
self.last_modified(mi.last_modified)
return data
def get_format(self, id, format):
format = format.upper()
fm = self.db.format_metadata(id, format, allow_cache=False)
if not fm:
raise cherrypy.HTTPError(404, 'book: %d does not have format: %s'%(id, format))
update_metadata = format in {'MOBI', 'EPUB', 'AZW3'}
mi = newmi = self.db.get_metadata(
id, index_is_id=True, cover_as_data=True, get_cover=update_metadata)
cherrypy.response.headers['Last-Modified'] = \
self.last_modified(max(fm['mtime'], mi.last_modified))
fmt = self.db.format(id, format, index_is_id=True, as_file=True,
mode='rb')
if fmt is None:
raise cherrypy.HTTPError(404, 'book: %d does not have format: %s'%(id, format))
mt = guess_type('dummy.'+format.lower())[0]
if mt is None:
mt = 'application/octet-stream'
cherrypy.response.headers['Content-Type'] = mt
if format.lower() in plugboard_content_server_formats:
# Get any plugboards for the content server
plugboards = self.db.prefs.get('plugboards', {})
cpb = find_plugboard(plugboard_content_server_value,
format.lower(), plugboards)
if cpb:
# Transform the metadata via the plugboard
newmi = mi.deepcopy_metadata()
newmi.template_to_attribute(mi, cpb)
if update_metadata:
# Write the updated file
from calibre.ebooks.metadata.meta import set_metadata
set_metadata(fmt, newmi, format.lower())
fmt.seek(0)
fmt.seek(0, 2)
cherrypy.response.headers['Content-Length'] = fmt.tell()
fmt.seek(0)
ua = cherrypy.request.headers.get('User-Agent', '').strip()
have_kobo_browser = self.is_kobo_browser(ua)
file_extension = "kepub.epub" if have_kobo_browser and format.lower() == "kepub" else format
au = authors_to_string(newmi.authors if newmi.authors else
[_('Unknown')])
title = newmi.title if newmi.title else _('Unknown')
fname = u'%s - %s_%s.%s'%(title[:30], au[:30], id, file_extension.lower())
fname = ascii_filename(fname).replace('"', '_')
cherrypy.response.headers['Content-Disposition'] = \
b'attachment; filename="%s"'%fname
cherrypy.response.body = fmt
cherrypy.response.timeout = 3600
return fmt
# }}}

View File

@ -1,135 +0,0 @@
#!/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 sys, os
from threading import Thread
from calibre.library.server import server_config as config
from calibre.library.server.base import LibraryServer
from calibre.constants import iswindows, plugins
import cherrypy
def start_threaded_server(db, opts):
server = LibraryServer(db, opts, embedded=True, show_tracebacks=False)
server.thread = Thread(target=server.start)
server.thread.setDaemon(True)
server.thread.start()
return server
def stop_threaded_server(server):
server.exit()
server.thread = None
def create_wsgi_app(path_to_library=None, prefix='', virtual_library=None):
'WSGI entry point'
from calibre.library import db
cherrypy.config.update({'environment': 'embedded'})
db = db(path_to_library)
parser = option_parser()
opts, args = parser.parse_args(['calibre-server'])
opts.url_prefix = prefix
opts.restriction = virtual_library
server = LibraryServer(db, opts, wsgi=True, show_tracebacks=True)
return cherrypy.Application(server, script_name=None, config=server.config)
def option_parser():
parser = config().option_parser('%prog '+ _(
'''[options]
Start the calibre content server. The calibre content server
exposes your calibre library over the internet. The default interface
allows you to browse you calibre library by categories. You can also
access an interface optimized for mobile browsers at /mobile and an
OPDS based interface for use with reading applications at /opds.
The OPDS interface is advertised via BonJour automatically.
'''
))
parser.add_option('--with-library', default=None,
help=_('Path to the library folder to serve with the content server'))
parser.add_option('--pidfile', default=None,
help=_('Write process PID to the specified file'))
parser.add_option('--daemonize', default=False, action='store_true',
help=_('Run process in background as a daemon. No effect on windows.'))
parser.add_option('--restriction', '--virtual-library', default=None,
help=_('Specifies a virtual library to be used for this invocation. '
'This option overrides any per-library settings specified'
' in the GUI. For compatibility, if the value is not a '
'virtual library but is a saved search, that saved search is used.'
' Also note that if you do not specify a restriction,'
' the value specified in the GUI (if any) will be used.'))
parser.add_option('--auto-reload', default=False, action='store_true',
help=_('Auto reload server when source code changes. May not'
' work in all environments.'))
return parser
def daemonize():
try:
pid = os.fork()
if pid > 0:
# exit first parent
sys.exit(0)
except OSError as e:
print >>sys.stderr, "fork #1 failed: %d (%s)" % (e.errno, e.strerror)
sys.exit(1)
# decouple from parent environment
os.chdir("/")
os.setsid()
os.umask(0)
# do second fork
try:
pid = os.fork()
if pid > 0:
# exit from second parent
sys.exit(0)
except OSError as e:
print >>sys.stderr, "fork #2 failed: %d (%s)" % (e.errno, e.strerror)
sys.exit(1)
# Redirect standard file descriptors.
try:
plugins['speedup'][0].detach(os.devnull)
except AttributeError: # people running from source without updated binaries
si = os.open(os.devnull, os.O_RDONLY)
so = os.open(os.devnull, os.O_WRONLY)
se = os.open(os.devnull, os.O_WRONLY)
os.dup2(si, sys.stdin.fileno())
os.dup2(so, sys.stdout.fileno())
os.dup2(se, sys.stderr.fileno())
def main(args=sys.argv):
from calibre.db.legacy import LibraryDatabase
parser = option_parser()
opts, args = parser.parse_args(args)
if opts.daemonize and not iswindows:
daemonize()
if opts.pidfile is not None:
from cherrypy.process.plugins import PIDFile
PIDFile(cherrypy.engine, opts.pidfile).subscribe()
cherrypy.log.screen = True
from calibre.utils.config import prefs
if opts.with_library is None:
opts.with_library = prefs['library_path']
if not opts.with_library:
print('No saved library path. Use the --with-library option'
' to specify the path to the library you want to use.')
return 1
db = LibraryDatabase(os.path.expanduser(opts.with_library))
server = LibraryServer(db, opts, show_tracebacks=opts.develop)
server.start()
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@ -1,312 +0,0 @@
#!/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 re, os
import __builtin__
from urllib import quote, urlencode
import cherrypy
from lxml import html
from lxml.html.builder import HTML, HEAD, TITLE, LINK, DIV, IMG, BODY, \
OPTION, SELECT, INPUT, FORM, SPAN, TABLE, TR, TD, A, HR, META
from calibre.library.server import custom_fields_to_display
from calibre.library.server.utils import strftime, format_tag_string
from calibre.ebooks.metadata import fmt_sidx
from calibre.constants import __appname__
from calibre import human_readable, isbytestring
from calibre.utils.cleantext import clean_xml_chars
from calibre.utils.date import utcfromtimestamp, as_local_time, is_date_undefined
from calibre.utils.filenames import ascii_filename
from calibre.utils.icu import sort_key
def CLASS(*args, **kwargs): # class is a reserved word in Python
kwargs['class'] = ' '.join(args)
return kwargs
def build_search_box(num, search, sort, order, prefix): # {{{
div = DIV(id='search_box')
form = FORM('Show ', method='get', action=prefix+'/mobile')
form.set('accept-charset', 'UTF-8')
div.append(form)
num_select = SELECT(name='num')
for option in (5, 10, 25, 100):
kwargs = {'value':str(option)}
if option == num:
kwargs['SELECTED'] = 'SELECTED'
num_select.append(OPTION(str(option), **kwargs))
num_select.tail = ' books matching '
form.append(num_select)
searchf = INPUT(name='search', id='s', value=search if search else '')
searchf.tail = ' sorted by '
form.append(searchf)
sort_select = SELECT(name='sort')
for option in ('date','author','title','rating','size','tags','series'):
kwargs = {'value':option}
if option == sort:
kwargs['SELECTED'] = 'SELECTED'
sort_select.append(OPTION(option, **kwargs))
form.append(sort_select)
order_select = SELECT(name='order')
for option in ('ascending','descending'):
kwargs = {'value':option}
if option == order:
kwargs['SELECTED'] = 'SELECTED'
order_select.append(OPTION(option, **kwargs))
form.append(order_select)
form.append(INPUT(id='go', type='submit', value='Search'))
return div
# }}}
def build_navigation(start, num, total, url_base): # {{{
end = min((start+num-1), total)
tagline = SPAN('Books %d to %d of %d'%(start, end, total),
style='display: block; text-align: center;')
left_buttons = TD(CLASS('button', style='text-align:left'))
right_buttons = TD(CLASS('button', style='text-align:right'))
if start > 1:
for t,s in [('First', 1), ('Previous', max(start-num,1))]:
left_buttons.append(A(t, href='%s&start=%d'%(url_base, s)))
if total > start + num:
for t,s in [('Next', start+num), ('Last', total-num+1)]:
right_buttons.append(A(t, href='%s&start=%d'%(url_base, s)))
buttons = TABLE(
TR(left_buttons, right_buttons),
CLASS('buttons'))
return DIV(tagline, buttons, CLASS('navigation'))
# }}}
def build_index(books, num, search, sort, order, start, total, url_base, CKEYS,
prefix, have_kobo_browser=False):
logo = DIV(IMG(src=prefix+'/static/calibre.png', alt=__appname__), id='logo')
search_box = build_search_box(num, search, sort, order, prefix)
navigation = build_navigation(start, num, total, prefix+url_base)
navigation2 = build_navigation(start, num, total, prefix+url_base)
bookt = TABLE(id='listing')
body = BODY(
logo,
search_box,
navigation,
HR(CLASS('spacer')),
bookt,
HR(CLASS('spacer')),
navigation2
)
# Book list {{{
for book in books:
thumbnail = TD(
IMG(type='image/jpeg', border='0',
src=prefix+'/get/thumb/%s' %
book['id']),
CLASS('thumbnail'))
data = TD()
for fmt in book['formats'].split(','):
if not fmt or fmt.lower().startswith('original_'):
continue
file_extension = "kepub.epub" if have_kobo_browser and fmt.lower() == "kepub" else fmt
a = quote(ascii_filename(book['authors']))
t = quote(ascii_filename(book['title']))
s = SPAN(
A(
fmt.lower(),
href=prefix+'/get/%s/%s-%s_%d.%s' % (fmt, a, t,
book['id'], file_extension.lower())
),
CLASS('button'))
s.tail = u''
data.append(s)
div = DIV(CLASS('data-container'))
data.append(div)
series = u'[%s - %s]'%(book['series'], book['series_index']) \
if book['series'] else ''
tags = u'Tags=[%s]'%book['tags'] if book['tags'] else ''
ctext = ''
for key in CKEYS:
val = book.get(key, None)
if val:
ctext += '%s=[%s] '%tuple(val.split(':#:'))
first = SPAN(u'\u202f%s %s by %s' % (clean_xml_chars(book['title']), clean_xml_chars(series),
clean_xml_chars(book['authors'])), CLASS('first-line'))
div.append(first)
second = SPAN(u'%s - %s %s %s' % (book['size'],
book['timestamp'],
tags, ctext), CLASS('second-line'))
div.append(second)
bookt.append(TR(thumbnail, data))
# }}}
body.append(DIV(
A(_('Switch to the full interface (non-mobile interface)'),
href=prefix+"/browse",
style="text-decoration: none; color: blue",
title=_('The full interface gives you many more features, '
'but it may not work well on a small screen')),
style="text-align:center"))
return HTML(
HEAD(
TITLE(__appname__ + ' Library'),
LINK(rel='icon', href='//calibre-ebook.com/favicon.ico',
type='image/x-icon'),
LINK(rel='stylesheet', type='text/css',
href=prefix+'/mobile/style.css'),
LINK(rel='apple-touch-icon', href="/static/calibre.png"),
META(name="robots", content="noindex")
), # End head
body
) # End html
class MobileServer(object):
'A view optimized for browsers in mobile devices'
MOBILE_UA = re.compile('(?i)(?:iPhone|Opera Mini|NetFront|webOS|Mobile|Android|imode|DoCoMo|Minimo|Blackberry|MIDP|Symbian|HD2|Kindle)')
def is_mobile_browser(self, ua):
match = self.MOBILE_UA.search(ua)
return match is not None and 'iPad' not in ua
def is_kobo_browser(self, ua):
return 'Kobo Touch' in ua
def add_routes(self, connect):
connect('mobile', '/mobile', self.mobile)
connect('mobile_css', '/mobile/style.css', self.mobile_css)
def mobile_css(self, *args, **kwargs):
path = P('content_server/mobile.css')
cherrypy.response.headers['Content-Type'] = 'text/css; charset=utf-8'
updated = utcfromtimestamp(os.stat(path).st_mtime)
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
with open(path, 'rb') as f:
ans = f.read()
return ans.replace('{prefix}', self.opts.url_prefix)
def mobile(self, start='1', num='25', sort='date', search='',
_=None, order='descending'):
'''
Serves metadata from the calibre database as XML.
:param sort: Sort results by ``sort``. Can be one of `title,author,rating`.
:param search: Filter results by ``search`` query. See :class:`SearchQueryParser` for query syntax
:param start,num: Return the slice `[start:start+num]` of the sorted and filtered results
:param _: Firefox seems to sometimes send this when using XMLHttpRequest with no caching
'''
try:
start = int(start)
except ValueError:
raise cherrypy.HTTPError(400, 'start: %s is not an integer'%start)
try:
num = int(num)
except ValueError:
raise cherrypy.HTTPError(400, 'num: %s is not an integer'%num)
if not search:
search = ''
if isbytestring(search):
search = search.decode('UTF-8')
ids = self.search_for_books(search)
FM = self.db.FIELD_MAP
items = [r for r in iter(self.db) if r[FM['id']] in ids]
if sort is not None:
self.sort(items, sort, (order.lower().strip() == 'ascending'))
CFM = self.db.field_metadata
CKEYS = [key for key in sorted(custom_fields_to_display(self.db),
key=lambda x:sort_key(CFM[x]['name']))]
# This method uses its own book dict, not the Metadata dict. The loop
# below could be changed to use db.get_metadata instead of reading
# info directly from the record made by the view, but it doesn't seem
# worth it at the moment.
books = []
for record in items[(start-1):(start-1)+num]:
book = {'formats':record[FM['formats']], 'size':record[FM['size']]}
if not book['formats']:
book['formats'] = ''
if not book['size']:
book['size'] = 0
book['size'] = human_readable(book['size'])
aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown')
aut_is = CFM['authors']['is_multiple']
authors = aut_is['list_to_ui'].join([i.replace('|', ',') for i in aus.split(',')])
book['authors'] = authors
book['series_index'] = fmt_sidx(float(record[FM['series_index']]))
book['series'] = record[FM['series']]
book['tags'] = format_tag_string(record[FM['tags']], ',',
no_tag_count=True)
book['title'] = record[FM['title']]
for x in ('timestamp', 'pubdate'):
dval = record[FM[x]]
if is_date_undefined(dval):
book[x] = ''
else:
book[x] = strftime('%d %b, %Y', as_local_time(dval))
book['id'] = record[FM['id']]
books.append(book)
for key in CKEYS:
def concat(name, val):
return '%s:#:%s'%(name, unicode(val))
mi = self.db.get_metadata(record[CFM['id']['rec_index']], index_is_id=True)
name, val = mi.format_field(key)
if not val:
continue
datatype = CFM[key]['datatype']
if datatype in ['comments']:
continue
if datatype == 'text' and CFM[key]['is_multiple']:
book[key] = concat(name,
format_tag_string(val,
CFM[key]['is_multiple']['ui_to_list'],
no_tag_count=True,
joinval=CFM[key]['is_multiple']['list_to_ui']))
else:
book[key] = concat(name, val)
updated = self.db.last_modified()
cherrypy.response.headers['Content-Type'] = 'text/html; charset=utf-8'
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
q = {b'search':search.encode('utf-8'), b'order':order.encode('utf-8'), b'sort':sort.encode('utf-8'), b'num':str(num).encode('utf-8')}
url_base = "/mobile?" + urlencode(q)
ua = cherrypy.request.headers.get('User-Agent', '').strip()
have_kobo_browser = self.is_kobo_browser(ua)
raw = html.tostring(build_index(books, num, search, sort, order,
start, len(ids), url_base, CKEYS,
self.opts.url_prefix,
have_kobo_browser=have_kobo_browser),
encoding='utf-8',
pretty_print=True)
# tostring's include_meta_content_type is broken
raw = raw.replace('<head>', '<head>\n'
'<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">')
return raw

View File

@ -1,660 +0,0 @@
#!/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 collections import OrderedDict
from lxml import etree, html
from lxml.builder import ElementMaker
import cherrypy
import routes
from calibre.constants import __appname__
from calibre.ebooks.metadata import fmt_sidx, rating_to_stars
from calibre.library.comments import comments_to_html
from calibre.library.server import custom_fields_to_display
from calibre.library.server.utils import format_tag_string, Offsets
from calibre import guess_type, prepare_string_for_xml as xml
from calibre.utils.icu import sort_key
from calibre.utils.date import as_utc, is_date_undefined
BASE_HREFS = {
0 : '/stanza',
1 : '/opds',
}
STANZA_FORMATS = frozenset(['epub', 'pdb', 'pdf', 'cbr', 'cbz', 'djvu'])
def url_for(name, version, **kwargs):
if not name.endswith('_'):
name += '_'
return routes.url_for(name+str(version), **kwargs)
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')
# Vocabulary for building OPDS feeds {{{
DC_NS = 'http://purl.org/dc/terms/'
E = ElementMaker(namespace='http://www.w3.org/2005/Atom',
nsmap={
None : 'http://www.w3.org/2005/Atom',
'dc' : DC_NS,
'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(base_href, *args, **kwargs):
kwargs['rel'] = 'search'
kwargs['title'] = 'Search'
kwargs['href'] = base_href+'/search/{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(base_href, updated, title, description, query, version=0):
href = base_href+'/navcatalog/'+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, base_href, version, updated,
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
link = NAVLINK(href=base_href + '/' + hexlify(iid))
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, base_href, version, updated):
id_ = 'calibre:category-group:'+category+':'+item.text
iid = item.text
link = NAVLINK(href=base_href + '/' + hexlify(iid))
return E.entry(
TITLE(item.text),
ID(id_),
UPDATED(updated),
E.content(_('%d items')%item.count, type='text'),
link
)
def ACQUISITION_ENTRY(item, version, db, updated, CFM, CKEYS, prefix):
FM = db.FIELD_MAP
title = item[FM['title']]
if not title:
title = _('Unknown')
authors = item[FM['authors']]
if not authors:
authors = _('Unknown')
authors = ' & '.join([i.replace('|', ',') for i in
authors.split(',')])
extra = []
rating = item[FM['rating']]
if rating > 0:
rating = rating_to_stars(rating)
extra.append(_('RATING: %s<br />')%rating)
tags = item[FM['tags']]
if tags:
extra.append(_('TAGS: %s<br />')%xml(format_tag_string(tags, ',',
ignore_max=True,
no_tag_count=True)))
series = item[FM['series']]
if series:
extra.append(_('SERIES: %(series)s [%(sidx)s]<br />')%
dict(series=xml(series),
sidx=fmt_sidx(float(item[FM['series_index']]))))
mi = db.get_metadata(item[CFM['id']['rec_index']], index_is_id=True)
for key in CKEYS:
name, val = mi.format_field(key)
if val:
datatype = CFM[key]['datatype']
if datatype == 'text' and CFM[key]['is_multiple']:
extra.append('%s: %s<br />'%
(xml(name),
xml(format_tag_string(val,
CFM[key]['is_multiple']['ui_to_list'],
ignore_max=True, no_tag_count=True,
joinval=CFM[key]['is_multiple']['list_to_ui']))))
elif datatype == 'comments' or (CFM[key]['datatype'] == 'composite' and
CFM[key]['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))))
comments = item[FM['comments']]
if comments:
comments = comments_to_html(comments)
extra.append(comments)
if extra:
extra = html_to_lxml('\n'.join(extra))
idm = 'calibre' if version == 0 else 'uuid'
id_ = 'urn:%s:%s'%(idm, item[FM['uuid']])
ans = E.entry(TITLE(title), E.author(E.name(authors)), ID(id_),
UPDATED(item[FM['last_modified']]), E.published(item[FM['timestamp']].isoformat()))
if mi.pubdate and not is_date_undefined(mi.pubdate):
ans.append(ans.makeelement('{%s}date' % DC_NS))
ans[-1].text = mi.pubdate.isoformat()
if len(extra):
ans.append(E.content(extra, type='xhtml'))
formats = item[FM['formats']]
if formats:
book_id = item[FM['id']]
for fmt in formats.split(','):
fmt = fmt.lower()
mt = guess_type('a.'+fmt)[0]
href = prefix + '/get/%s/%s'%(fmt, book_id)
if mt:
link = E.link(type=mt, href=href)
if version > 0:
link.set('rel', "http://opds-spec.org/acquisition")
fm = db.format_metadata(book_id, fmt)
if fm:
link.set('length', str(fm['size']))
link.set('mtime', fm['mtime'].isoformat())
ans.append(link)
ans.append(E.link(type='image/jpeg', href=prefix+'/get/cover/%s'%item[FM['id']],
rel="x-stanza-cover-image" if version == 0 else
"http://opds-spec.org/cover"))
ans.append(E.link(type='image/jpeg', href=prefix+'/get/thumb/%s'%item[FM['id']],
rel="x-stanza-cover-image-thumbnail" if version == 0 else
"http://opds-spec.org/thumbnail"))
return ans
# }}}
default_feed_title = __appname__ + ' ' + _('Library')
class Feed(object): # {{{
def __init__(self, id_, updated, version, subtitle=None,
title=None,
up_link=None, first_link=None, last_link=None,
next_link=None, previous_link=None):
self.base_href = url_for('opds', version)
self.root = \
FEED(
TITLE(title or default_feed_title),
AUTHOR(__appname__, uri='http://calibre-ebook.com'),
ID(id_),
ICON('/favicon.png'),
UPDATED(updated),
SEARCH_LINK(self.base_href),
START_LINK(href=self.base_href)
)
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))
def __str__(self):
return etree.tostring(self.root, pretty_print=True, encoding='utf-8',
xml_declaration=True)
# }}}
class TopLevel(Feed): # {{{
def __init__(self,
updated, # datetime object in UTC
categories,
version,
id_='urn:calibre:main',
subtitle=_('Books in your library')
):
Feed.__init__(self, id_, updated, version, subtitle=subtitle)
subc = partial(NAVCATALOG_ENTRY, self.base_href, updated,
version=version)
subcatalogs = [subc(_('By ')+title,
_('Books sorted by ') + desc, q) for title, desc, q in
categories]
for x in subcatalogs:
self.root.append(x)
# }}}
class NavFeed(Feed):
def __init__(self, id_, updated, version, 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, version, **kwargs)
class AcquisitionFeed(NavFeed):
def __init__(self, updated, id_, items, offsets, page_url, up_url, version,
db, prefix, title=None):
NavFeed.__init__(self, id_, updated, version, offsets, page_url, up_url, title=title)
CFM = db.field_metadata
CKEYS = [key for key in sorted(custom_fields_to_display(db),
key=lambda x: sort_key(CFM[x]['name']))]
for item in items:
self.root.append(ACQUISITION_ENTRY(item, version, db, updated,
CFM, CKEYS, prefix))
class CategoryFeed(NavFeed):
def __init__(self, items, which, id_, updated, version, offsets, page_url, up_url, db, title=None):
NavFeed.__init__(self, id_, updated, version, offsets, page_url, up_url, title=title)
base_href = self.base_href + '/category/' + hexlify(which)
ignore_count = False
if which == 'search':
ignore_count = True
for item in items:
self.root.append(CATALOG_ENTRY(item, item.category, base_href, version,
updated, ignore_count=ignore_count,
add_kind=which != item.category))
class CategoryGroupFeed(NavFeed):
def __init__(self, items, which, id_, updated, version, offsets, page_url, up_url, title=None):
NavFeed.__init__(self, id_, updated, version, offsets, page_url, up_url, title=title)
base_href = self.base_href + '/categorygroup/' + hexlify(which)
for item in items:
self.root.append(CATALOG_GROUP_ENTRY(item, which, base_href, version, updated))
class OPDSServer(object):
def add_routes(self, connect):
for version in (0, 1):
base_href = BASE_HREFS[version]
ver = str(version)
connect('opds_'+ver, base_href, self.opds, version=version)
connect('opdst_'+ver, base_href+'/', self.opds, version=version)
connect('opdsnavcatalog_'+ver, base_href+'/navcatalog/{which}',
self.opds_navcatalog, version=version)
connect('opdscategory_'+ver, base_href+'/category/{category}/{which}',
self.opds_category, version=version)
connect('opdscategorygroup_'+ver, base_href+'/categorygroup/{category}/{which}',
self.opds_category_group, version=version)
connect('opdssearch_'+ver, base_href+'/search/{query}',
self.opds_search, version=version)
def get_opds_allowed_ids_for_version(self, version):
search = '' if version > 0 else ' or '.join(['format:='+x for x in
STANZA_FORMATS])
ids = self.search_cache(search)
return ids
def get_opds_acquisition_feed(self, ids, offset, page_url, up_url, id_,
sort_by='title', ascending=True, version=0, feed_title=None):
idx = self.db.FIELD_MAP['id']
ids &= self.get_opds_allowed_ids_for_version(version)
if not ids:
raise cherrypy.HTTPError(404, 'No books found')
items = [x for x in self.db.data.iterall() if x[idx] in ids]
self.sort(items, sort_by, ascending)
max_items = self.opts.max_opds_items
offsets = Offsets(offset, max_items, len(items))
items = items[offsets.offset:offsets.offset+max_items]
updated = self.db.last_modified()
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
cherrypy.response.headers['Content-Type'] = 'application/atom+xml;profile=opds-catalog'
return str(AcquisitionFeed(updated, id_, items, offsets,
page_url, up_url, version, self.db,
self.opts.url_prefix, title=feed_title))
def opds_search(self, query=None, version=0, offset=0):
try:
offset = int(offset)
version = int(version)
except:
raise cherrypy.HTTPError(404, 'Not found')
if query is None or version not in BASE_HREFS:
raise cherrypy.HTTPError(404, 'Not found')
try:
ids = self.search_cache(query)
except:
raise cherrypy.HTTPError(404, 'Search: %r not understood'%query)
page_url = url_for('opdssearch', version, query=query)
return self.get_opds_acquisition_feed(ids, offset, page_url,
url_for('opds', version), 'calibre-search:'+query,
version=version)
def get_opds_all_books(self, which, page_url, up_url, version=0, offset=0):
try:
offset = int(offset)
version = int(version)
except:
raise cherrypy.HTTPError(404, 'Not found')
if which not in ('title', 'newest') or version not in BASE_HREFS:
raise cherrypy.HTTPError(404, '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 = self.get_opds_allowed_ids_for_version(version)
return self.get_opds_acquisition_feed(ids, offset, page_url, up_url,
id_='calibre-all:'+sort, sort_by=sort, ascending=ascending,
version=version, feed_title=feed_title)
# Categories {{{
def opds_category_group(self, category=None, which=None, version=0, offset=0):
try:
offset = int(offset)
version = int(version)
except:
raise cherrypy.HTTPError(404, 'Not found')
if not which or not category or version not in BASE_HREFS:
raise cherrypy.HTTPError(404, 'Not found')
categories = self.categories_cache(
self.get_opds_allowed_ids_for_version(version))
page_url = url_for('opdscategorygroup', version, category=category, which=which)
category = unhexlify(category)
if category not in categories:
raise cherrypy.HTTPError(404, 'Category %r not found'%which)
category_meta = self.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 = url_for('opdsnavcatalog', version, 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 cherrypy.HTTPError(404, 'No items in group %r:%r'%(category,
which))
updated = self.db.last_modified()
id_ = 'calibre-category-group-feed:'+category+':'+which
max_items = self.opts.max_opds_items
offsets = Offsets(offset, max_items, len(items))
items = list(items)[offsets.offset:offsets.offset+max_items]
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
cherrypy.response.headers['Content-Type'] = 'application/atom+xml'
return str(CategoryFeed(items, category, id_, updated, version, offsets,
page_url, up_url, self.db, title=feed_title))
def opds_navcatalog(self, which=None, version=0, offset=0):
try:
offset = int(offset)
version = int(version)
except:
raise cherrypy.HTTPError(404, 'Not found')
if not which or version not in BASE_HREFS:
raise cherrypy.HTTPError(404, 'Not found')
page_url = url_for('opdsnavcatalog', version, which=which)
up_url = url_for('opds', version)
which = unhexlify(which)
type_ = which[0]
which = which[1:]
if type_ == 'O':
return self.get_opds_all_books(which, page_url, up_url,
version=version, offset=offset)
elif type_ == 'N':
return self.get_opds_navcatalog(which, page_url, up_url,
version=version, offset=offset)
raise cherrypy.HTTPError(404, 'Not found')
def get_opds_navcatalog(self, which, page_url, up_url, version=0, offset=0):
categories = self.categories_cache(
self.get_opds_allowed_ids_for_version(version))
if which not in categories:
raise cherrypy.HTTPError(404, 'Category %r not found'%which)
items = categories[which]
updated = self.db.last_modified()
category_meta = self.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 = self.opts.max_opds_ungrouped_items
if len(items) <= MAX_ITEMS:
max_items = self.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, version, offsets,
page_url, up_url, self.db, title=feed_title)
else:
class Group:
def __init__(self, text, count):
self.text, self.count = 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 = self.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, version, offsets,
page_url, up_url, title=feed_title)
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
cherrypy.response.headers['Content-Type'] = 'application/atom+xml'
return str(ans)
def opds_category(self, category=None, which=None, version=0, offset=0):
try:
offset = int(offset)
version = int(version)
except:
raise cherrypy.HTTPError(404, 'Not found')
if not which or not category or version not in BASE_HREFS:
raise cherrypy.HTTPError(404, 'Not found')
page_url = url_for('opdscategory', version, which=which,
category=category)
up_url = url_for('opdsnavcatalog', version, 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:
# Might be a composite column, where we have the lookup key
if not (category in self.db.field_metadata and
self.db.field_metadata[category]['datatype'] == 'composite'):
raise cherrypy.HTTPError(404, 'Tag %r not found'%which)
categories = self.categories_cache(
self.get_opds_allowed_ids_for_version(version))
if category not in categories:
raise cherrypy.HTTPError(404, 'Category %r not found'%which)
if category == 'search':
try:
ids = self.search_cache('search:"%s"'%which)
except:
raise cherrypy.HTTPError(404, 'Search: %r not understood'%which)
return self.get_opds_acquisition_feed(ids, offset, page_url,
up_url, 'calibre-search:'+which,
version=version)
if type_ != 'I':
raise cherrypy.HTTPError(404, 'Non id categories not supported')
q = category
if q == 'news':
q = 'tags'
ids = self.db.get_books_for_category(q, which)
sort_by = 'series' if category == 'series' else 'title'
return self.get_opds_acquisition_feed(ids, offset, page_url,
up_url, 'calibre-category:'+category+':'+str(which),
version=version, sort_by=sort_by)
# }}}
def opds(self, version=0):
version = int(version)
if version not in BASE_HREFS:
raise cherrypy.HTTPError(404, 'Not found')
categories = self.categories_cache(
self.get_opds_allowed_ids_for_version(version))
category_meta = self.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
if category_meta.is_ignorable_field(category) and \
category not in custom_fields_to_display(self.db):
continue
cats.append((meta['name'], meta['name'], 'N'+category))
updated = self.db.last_modified()
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
cherrypy.response.headers['Content-Type'] = 'application/atom+xml'
feed = TopLevel(updated, cats, version)
return str(feed)

View File

@ -1,194 +0,0 @@
#!/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 time, sys, hashlib, binascii, random, os
from urllib import quote as quote_, unquote as unquote_
from functools import wraps
import cherrypy
from cherrypy.lib.auth_digest import digest_auth, get_ha1_dict_plain
from calibre import strftime as _strftime, prints, isbytestring
from calibre.utils.date import now as nowf
from calibre.utils.config import tweaks
from calibre.utils.icu import sort_key
class Offsets(object):
'Calculate offsets for a paginated view'
def __init__(self, offset, delta, total):
if offset < 0:
offset = 0
if offset >= total:
raise cherrypy.HTTPError(404, '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
def expose(func):
@wraps(func)
def do(*args, **kwargs):
self = func.im_self
if self.opts.develop:
start = time.time()
dict.update(cherrypy.response.headers, {'Server':self.server_name})
if not self.embedded:
self.db.check_if_modified()
ans = func(*args, **kwargs)
if self.opts.develop:
prints('Function', func.__name__, 'called with args:', args, kwargs)
prints('\tTime:', func.__name__, time.time()-start)
return ans
return do
class AuthController(object):
'''
Implement Digest authentication for the content server. Android browsers
cannot handle HTTP AUTH when downloading files, as the download is handed
off to a separate process. So we use a cookie based authentication scheme
for some endpoints (/get) to allow downloads to work on android. Apparently,
cookies are passed to the download process. The cookie expires after
MAX_AGE seconds.
The android browser appears to send a GET request to the server and only if
that request succeeds is the download handed off to the download process.
Therefore, even if the user clicks Get after MAX_AGE, it should still work.
In fact, we could reduce MAX_AGE, but we leave it high as the download
process might have downloads queued and therefore not start the download
immediately.
Note that this makes the server vulnerable to session-hijacking (i.e. some
one can sniff the traffic and create their own requests to /get with the
appropriate cookie, for an hour). The fix is to use https, but since this
is usually run as a private server, that cannot be done. If you care about
this vulnerability, run the server behind a reverse proxy that uses HTTPS.
'''
MAX_AGE = 3600 # Number of seconds after a successful digest auth for which
# the cookie auth will be allowed
def __init__(self, realm, users_dict):
self.realm = realm
self.users_dict = users_dict
self.secret = bytes(binascii.hexlify(os.urandom(random.randint(20,
30))))
self.cookie_name = 'android_workaround'
self.key_order = random.choice(('%(t)s:%(s)s', '%(s)s:%(t)s'))
def hashit(self, raw):
return hashlib.sha256(raw).hexdigest()
def __call__(self, func, allow_cookie_auth):
@wraps(func)
def authenticate(*args, **kwargs):
cookie = cherrypy.request.cookie.get(self.cookie_name, None)
if not (allow_cookie_auth and self.is_valid(cookie)):
digest_auth(self.realm, get_ha1_dict_plain(self.users_dict),
self.secret)
cookie = cherrypy.response.cookie
cookie[self.cookie_name] = self.generate_cookie()
cookie[self.cookie_name]['path'] = '/'
cookie[self.cookie_name]['version'] = '1'
return func(*args, **kwargs)
authenticate.im_self = func.im_self
return authenticate
def generate_cookie(self, timestamp=None):
'''
Generate a cookie. The cookie contains a plain text timestamp and a
hash of the timestamp and the server secret.
'''
timestamp = int(time.time()) if timestamp is None else timestamp
key = self.hashit(self.key_order%dict(t=timestamp, s=self.secret))
return '%d:%s'%(timestamp, key)
def is_valid(self, cookie):
'''
Check that cookie has not been spoofed (i.e. verify the declared
timestamp against the hashed timestamp). If the timestamps match, check
that the cookie has not expired. Return True iff the cookie has not
been spoofed and has not expired.
'''
try:
timestamp, hashpart = cookie.value.split(':', 1)
timestamp = int(timestamp)
except:
return False
s_timestamp, s_hashpart = self.generate_cookie(timestamp).split(':', 1)
is_valid = s_hashpart == hashpart
return (is_valid and (time.time() - timestamp) < self.MAX_AGE)
def strftime(fmt='%Y/%m/%d %H:%M:%S', dt=None):
if not hasattr(dt, 'timetuple'):
dt = nowf()
dt = dt.timetuple()
try:
return _strftime(fmt, dt)
except:
return _strftime(fmt, nowf().timetuple())
def format_tag_string(tags, sep, ignore_max=False, no_tag_count=False, joinval=', '):
MAX = sys.maxint if ignore_max else tweaks['max_content_server_tags_shown']
if tags:
tlist = [t.strip() for t in tags.split(sep)]
else:
tlist = []
tlist.sort(key=sort_key)
if len(tlist) > MAX:
tlist = tlist[:MAX]+['...']
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 ''
def quote(s):
if isinstance(s, unicode):
s = s.encode('utf-8')
return quote_(s)
def unquote(s):
ans = unquote_(s)
if isbytestring(ans):
ans = ans.decode('utf-8')
return ans
def cookie_time_fmt(time_t):
return time.strftime('%a, %d-%b-%Y %H:%M:%S GMT', time_t)
def cookie_max_age_to_expires(max_age):
gmt_expiration_time = time.gmtime(time.time() + max_age)
return cookie_time_fmt(gmt_expiration_time)

View File

@ -1,149 +0,0 @@
#!/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 __builtin__
import cherrypy
from lxml.builder import ElementMaker
from lxml import etree
from calibre.library.server import custom_fields_to_display
from calibre.library.server.utils import strftime, format_tag_string
from calibre.ebooks.metadata import fmt_sidx
from calibre.constants import preferred_encoding
from calibre import isbytestring
from calibre.utils.filenames import ascii_filename
from calibre.utils.icu import sort_key
E = ElementMaker()
class XMLServer(object):
'Serves XML and the Ajax based HTML frontend'
def add_routes(self, connect):
connect('xml', '/xml', self.xml)
def xml(self, start='0', num='50', sort=None, search=None,
_=None, order='ascending'):
'''
Serves metadata from the calibre database as XML.
:param sort: Sort results by ``sort``. Can be one of `title,author,rating`.
:param search: Filter results by ``search`` query. See :class:`SearchQueryParser` for query syntax
:param start,num: Return the slice `[start:start+num]` of the sorted and filtered results
:param _: Firefox seems to sometimes send this when using XMLHttpRequest with no caching
'''
try:
start = int(start)
except ValueError:
raise cherrypy.HTTPError(400, 'start: %s is not an integer'%start)
try:
num = int(num)
except ValueError:
raise cherrypy.HTTPError(400, 'num: %s is not an integer'%num)
order = order.lower().strip() == 'ascending'
if not search:
search = ''
if isbytestring(search):
search = search.decode('UTF-8')
ids = self.search_for_books(search)
FM = self.db.FIELD_MAP
items = [r for r in iter(self.db) if r[FM['id']] in ids]
if sort is not None:
self.sort(items, sort, order)
books = []
def serialize(x):
if isinstance(x, unicode):
return x
if isbytestring(x):
return x.decode(preferred_encoding, 'replace')
return unicode(x)
# This method uses its own book dict, not the Metadata dict. The loop
# below could be changed to use db.get_metadata instead of reading
# info directly from the record made by the view, but it doesn't seem
# worth it at the moment.
for record in items[start:start+num]:
kwargs = {}
aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown')
authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
kwargs['authors'] = authors
kwargs['series_index'] = \
fmt_sidx(float(record[FM['series_index']]))
for x in ('timestamp', 'pubdate'):
kwargs[x] = strftime('%Y/%m/%d %H:%M:%S', record[FM[x]])
for x in ('id', 'title', 'sort', 'author_sort', 'rating', 'size'):
kwargs[x] = serialize(record[FM[x]])
for x in ('formats', 'series', 'tags', 'publisher',
'comments', 'identifiers'):
y = record[FM[x]]
if x == 'tags':
y = format_tag_string(y, ',', ignore_max=True)
kwargs[x] = serialize(y) if y else ''
isbn = self.db.isbn(record[FM['id']], index_is_id=True)
kwargs['isbn'] = serialize(isbn if isbn else '')
kwargs['safe_title'] = ascii_filename(kwargs['title'])
c = kwargs.pop('comments')
CFM = self.db.field_metadata
CKEYS = [key for key in sorted(custom_fields_to_display(self.db),
key=lambda x: sort_key(CFM[x]['name']))]
custcols = []
for key in CKEYS:
def concat(name, val):
return '%s:#:%s'%(name, unicode(val))
mi = self.db.get_metadata(record[CFM['id']['rec_index']], index_is_id=True)
name, val = mi.format_field(key)
if not val:
continue
datatype = CFM[key]['datatype']
if datatype in ['comments']:
continue
k = str('CF_'+key[1:])
name = CFM[key]['name']
custcols.append(k)
if datatype == 'text' and CFM[key]['is_multiple']:
kwargs[k] = \
concat('#T#'+name,
format_tag_string(val,
CFM[key]['is_multiple']['ui_to_list'],
ignore_max=True,
joinval=CFM[key]['is_multiple']['list_to_ui']))
else:
kwargs[k] = concat(name, val)
kwargs['custcols'] = ','.join(custcols)
books.append(E.book(c, **kwargs))
updated = self.db.last_modified()
kwargs = dict(
start=str(start),
updated=updated.strftime('%Y-%m-%dT%H:%M:%S+00:00'),
total=str(len(ids)),
num=str(len(books)))
ans = E.library(*books, **kwargs)
cherrypy.response.headers['Content-Type'] = 'text/xml'
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
return etree.tostring(ans, encoding='utf-8', pretty_print=True,
xml_declaration=True)

View File

@ -1,3 +1,3 @@
Rewrite server integration with nginx/apache section
Grep for from calibre.library.server and port all code that uses it
Grep for from library.server and port all code that uses it