diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py index f92c52e204..9d108d3807 100644 --- a/src/calibre/gui2/dialogs/config/__init__.py +++ b/src/calibre/gui2/dialogs/config/__init__.py @@ -20,7 +20,7 @@ from calibre.gui2 import choose_dir, error_dialog, config, \ from calibre.utils.config import prefs from calibre.ebooks import BOOK_EXTENSIONS from calibre.ebooks.oeb.iterator import is_supported -from calibre.library import server_config +from calibre.library.server import server_config from calibre.customize.ui import initialized_plugins, is_disabled, enable_plugin, \ disable_plugin, customize_plugin, \ plugin_customization, add_plugin, \ @@ -770,7 +770,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): def start_server(self): self.set_server_options() - from calibre.library.server import start_threaded_server + from calibre.library.server.main import start_threaded_server self.server = start_threaded_server(self.db, server_config().parse()) while not self.server.is_running and self.server.exception is None: time.sleep(1) @@ -783,7 +783,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): self.stop.setEnabled(True) def stop_server(self): - from calibre.library.server import stop_threaded_server + from calibre.library.server.main import stop_threaded_server stop_threaded_server(self.server) self.server = None self.start.setEnabled(True) diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 6ed51d3eff..2b647fe5c8 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -617,8 +617,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): if config['autolaunch_server']: - from calibre.library.server import start_threaded_server - from calibre.library import server_config + from calibre.library.server.main import start_threaded_server + from calibre.library.server import server_config self.content_server = start_threaded_server( db, server_config().parse()) self.test_server_timer = QTimer.singleShot(10000, self.test_server) diff --git a/src/calibre/gui2/wizard/__init__.py b/src/calibre/gui2/wizard/__init__.py index 0ac6c0a00b..d7bcc268f5 100644 --- a/src/calibre/gui2/wizard/__init__.py +++ b/src/calibre/gui2/wizard/__init__.py @@ -331,7 +331,7 @@ class StanzaPage(QWizardPage, StanzaUI): p = self.set_port() if p is not None: - from calibre.library import server_config + from calibre.library.server import server_config c = server_config() c.set('port', p) diff --git a/src/calibre/library/__init__.py b/src/calibre/library/__init__.py index 3c98db5e8a..18aec71fc8 100644 --- a/src/calibre/library/__init__.py +++ b/src/calibre/library/__init__.py @@ -1,31 +1,6 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' ''' Code to manage ebook library''' -from calibre.utils.config import Config, StringConfig - - -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 automatically restarts on file changes and serves code files (html, css, js) from the file system instead of calibre\'s resource system.') - 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.')) - return c def db(): from calibre.library.database2 import LibraryDatabase2 diff --git a/src/calibre/library/server.py b/src/calibre/library/server.py deleted file mode 100644 index 1a15492da3..0000000000 --- a/src/calibre/library/server.py +++ /dev/null @@ -1,955 +0,0 @@ -#!/usr/bin/env python -__license__ = 'GPL v3' -__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' -__docformat__ = 'restructuredtext en' - -''' -HTTP server for remote access to the calibre database. -''' - -import sys, textwrap, operator, os, re, logging, cStringIO, copy -import __builtin__ -from itertools import repeat -from logging.handlers import RotatingFileHandler -from threading import Thread - -import cherrypy -try: - from PIL import Image as PILImage - PILImage -except ImportError: - import Image as PILImage - -from calibre.constants import __version__, __appname__, iswindows -from calibre.utils.genshi.template import MarkupTemplate -from calibre import fit_image, guess_type, prepare_string_for_xml, \ - strftime as _strftime -from calibre.library import server_config as config -from calibre.library.database2 import LibraryDatabase2 -from calibre.utils.config import config_dir -from calibre.utils.mdns import publish as publish_zeroconf, \ - stop_server as stop_zeroconf, get_external_ip -from calibre.ebooks.metadata import fmt_sidx, title_sort -from calibre.utils.date import now as nowf, fromtimestamp - -listen_on = '0.0.0.0' - -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 expose(func): - - def do(self, *args, **kwargs): - dict.update(cherrypy.response.headers, {'Server':self.server_name}) - if not self.embedded: - self.db.check_if_modified() - return func(self, *args, **kwargs) - - return cherrypy.expose(do) - -log_access_file = os.path.join(config_dir, 'server_access_log.txt') -log_error_file = os.path.join(config_dir, 'server_error_log.txt') - - -class LibraryServer(object): - - server_name = __appname__ + '/' + __version__ - - BOOK = textwrap.dedent('''\ - ${r[FM['comments']] if r[FM['comments']] else ''} - - ''') - - MOBILE_UA = re.compile('(?i)(?:iPhone|Opera Mini|NetFront|webOS|Mobile|Android|imode|DoCoMo|Minimo|Blackberry|MIDP|Symbian|HD2)') - - MOBILE_BOOK = textwrap.dedent('''\ - - - - - - - ${format.lower()}  - - ${r[FM['title']]}${(' ['+r[FM['series']]+'-'+r[FM['series_index']]+']') if r[FM['series']] else ''} by ${authors} - ${r[FM['size']]/1024}k - ${r[FM['publisher']] if r[FM['publisher']] else ''} ${pubdate} ${'['+r[FM['tags']]+']' if r[FM['tags']] else ''} - - - ''') - - MOBILE = MarkupTemplate(textwrap.dedent('''\ - - - - - - - - - -
- - - ${Markup(book)} - -
- - - ''')) - - LIBRARY = MarkupTemplate(textwrap.dedent('''\ - - - - ${Markup(book)} - - - ''')) - - STANZA_ENTRY=MarkupTemplate(textwrap.dedent('''\ - - ${record[FM['title']]} - urn:calibre:${urn} - ${authors} - ${timestamp} - - - - -
${Markup(extra)}${record[FM['comments']]}
-
-
- ''')) - - STANZA_SUBCATALOG_ENTRY=MarkupTemplate(textwrap.dedent('''\ - - ${title} - urn:calibre:${id} - ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} - - ${count} books - - ''')) - - STANZA = MarkupTemplate(textwrap.dedent('''\ - - - calibre Library - $id - ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} - - ${Markup(next_link)} - - calibre - http://calibre-ebook.com - - - ${subtitle} - - - ${Markup(entry)} - - - ''')) - - STANZA_MAIN = MarkupTemplate(textwrap.dedent('''\ - - - calibre Library - $id - ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} - - - calibre - http://calibre-ebook.com - - - ${subtitle} - - - By Author - urn:uuid:fc000fa0-8c23-11de-a31d-0002a5d5c51b - ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} - - Books sorted by Author - - - By Title - urn:uuid:1df4fe40-8c24-11de-b4c6-0002a5d5c51b - ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} - - Books sorted by Title - - - By Newest - urn:uuid:3c6d4940-8c24-11de-a4d7-0002a5d5c51b - ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} - - Books sorted by Date - - - By Tag - urn:uuid:824921e8-db8a-4e61-7d38-f1ce41502853 - ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} - - Books sorted by Tags - - - By Series - urn:uuid:512a5e50-a88f-f6b8-82aa-8f129c719f61 - ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} - - Books sorted by Series - - - ''')) - - - def __init__(self, db, opts, embedded=False, show_tracebacks=True): - self.db = db - for item in self.db: - item - break - self.opts = opts - self.embedded = embedded - self.max_cover_width, self.max_cover_height = \ - map(int, self.opts.max_cover.split('x')) - self.max_stanza_items = opts.max_opds_items - 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() - cherrypy.config.update({ - 'log.screen' : opts.develop, - 'engine.autoreload_on' : opts.develop, - 'tools.log_headers.on' : opts.develop, - '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 - }) - if embedded: - cherrypy.config.update({'engine.SIGHUP' : None, - 'engine.SIGTERM' : None,}) - self.config = {'global': { - 'tools.gzip.on' : True, - 'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/xml', 'text/javascript', 'text/css'], - }} - if opts.password: - self.config['/'] = { - 'tools.digest_auth.on' : True, - 'tools.digest_auth.realm' : (_('Password to access your calibre library. Username is ') + opts.username.strip()).encode('ascii', 'replace'), - 'tools.digest_auth.users' : {opts.username.strip():opts.password.strip()}, - } - - self.is_running = False - self.exception = None - - 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(self): - self.is_running = False - self.setup_loggers() - cherrypy.tree.mount(self, '', config=self.config) - try: - try: - cherrypy.engine.start() - except: - ip = get_external_ip() - if not ip or ip == '127.0.0.1': - raise - cherrypy.log('Trying to bind to single interface: '+ip) - cherrypy.config.update({'server.socket_host' : ip}) - cherrypy.engine.start() - - self.is_running = True - try: - publish_zeroconf('Books in calibre', '_stanza._tcp', - self.opts.port, {'path':'/stanza'}) - except: - import traceback - cherrypy.log.error('Failed to start BonJour:') - cherrypy.log.error(traceback.format_exc()) - cherrypy.engine.block() - except Exception, e: - self.exception = e - finally: - self.is_running = False - try: - stop_zeroconf() - except: - import traceback - cherrypy.log.error('Failed to stop BonJour:') - cherrypy.log.error(traceback.format_exc()) - - def exit(self): - try: - cherrypy.engine.exit() - finally: - cherrypy.server.httpserver = None - - def get_cover(self, id, thumbnail=False): - cover = self.db.cover(id, index_is_id=True, as_file=False) - if cover is None: - cover = self.default_cover - cherrypy.response.headers['Content-Type'] = 'image/jpeg' - cherrypy.response.timeout = 3600 - path = getattr(cover, 'name', False) - updated = fromtimestamp(os.stat(path).st_mtime) if path and \ - os.access(path, os.R_OK) else self.build_time - cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) - try: - f = cStringIO.StringIO(cover) - try: - im = PILImage.open(f) - except IOError: - raise cherrypy.HTTPError(404, 'No valid cover found') - width, height = im.size - scaled, width, height = fit_image(width, height, - 60 if thumbnail else self.max_cover_width, - 80 if thumbnail else self.max_cover_height) - if not scaled: - return cover - im = im.resize((int(width), int(height)), PILImage.ANTIALIAS) - of = cStringIO.StringIO() - im.convert('RGB').save(of, 'JPEG') - return of.getvalue() - except Exception, err: - import traceback - cherrypy.log.error('Failed to generate cover:') - cherrypy.log.error(traceback.print_exc()) - raise cherrypy.HTTPError(404, 'Failed to generate cover: %s'%err) - - def get_format(self, id, format): - format = format.upper() - 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)) - if format == 'EPUB': - from tempfile import TemporaryFile - from calibre.ebooks.metadata.meta import set_metadata - raw = fmt.read() - fmt = TemporaryFile() - fmt.write(raw) - fmt.seek(0) - set_metadata(fmt, self.db.get_metadata(id, index_is_id=True), - 'epub') - fmt.seek(0) - mt = guess_type('dummy.'+format.lower())[0] - if mt is None: - mt = 'application/octet-stream' - cherrypy.response.headers['Content-Type'] = mt - cherrypy.response.timeout = 3600 - path = getattr(fmt, 'name', None) - if path and os.path.exists(path): - updated = fromtimestamp(os.stat(path).st_mtime) - cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) - return fmt.read() - - def sort(self, items, field, order): - field = field.lower().strip() - if field == 'author': - field = 'authors' - if field == 'date': - field = 'timestamp' - if field not in ('title', 'authors', 'rating', 'timestamp', 'tags', 'size', 'series'): - raise cherrypy.HTTPError(400, '%s is not a valid sort field'%field) - cmpf = cmp if field in ('rating', 'size', 'timestamp') else \ - lambda x, y: cmp(x.lower() if x else '', y.lower() if y else '') - if field == 'series': - items.sort(cmp=self.seriescmp, reverse=not order) - else: - field = self.db.FIELD_MAP[field] - getter = operator.itemgetter(field) - items.sort(cmp=lambda x, y: cmpf(getter(x), getter(y)), reverse=not order) - - def seriescmp(self, x, y): - si = self.db.FIELD_MAP['series'] - try: - ans = cmp(x[si].lower(), y[si].lower()) - except AttributeError: # Some entries may be None - ans = cmp(x[si], y[si]) - if ans != 0: return ans - return cmp(x[self.db.FIELD_MAP['series_index']], y[self.db.FIELD_MAP['series_index']]) - - - def last_modified(self, 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 get_matches(self, location, query): - base = self.db.data.get_matches(location, query) - epub = self.db.data.get_matches('format', '=epub') - pdb = self.db.data.get_matches('format', '=pdb') - return base.intersection(epub.union(pdb)) - - def stanza_sortby_subcategory(self, updated, sortby, offset): - pat = re.compile(r'\(.*\)') - - def clean_author(x): - return pat.sub('', x).strip() - - def author_cmp(x, y): - x = x if ',' in x else clean_author(x).rpartition(' ')[-1] - y = y if ',' in y else clean_author(y).rpartition(' ')[-1] - return cmp(x.lower(), y.lower()) - - def get_author(x): - pref, ___, suff = clean_author(x).rpartition(' ') - return suff + (', '+pref) if pref else suff - - - what, subtitle = sortby[2:], '' - if sortby == 'byseries': - data = self.db.all_series() - data = [(x[0], x[1], len(self.get_matches('series', '='+x[1]))) for x in data] - subtitle = 'Books by series' - elif sortby == 'byauthor': - data = self.db.all_authors() - data = [(x[0], x[1], len(self.get_matches('authors', '='+x[1]))) for x in data] - subtitle = 'Books by author' - elif sortby == 'bytag': - data = self.db.all_tags2() - data = [(x[0], x[1], len(self.get_matches('tags', '='+x[1]))) for x in data] - subtitle = 'Books by tag' - fcmp = author_cmp if sortby == 'byauthor' else cmp - data = [x for x in data if x[2] > 0] - data.sort(cmp=lambda x, y: fcmp(x[1], y[1])) - next_offset = offset + self.max_stanza_items - rdata = data[offset:next_offset] - if next_offset >= len(data): - next_offset = -1 - gt = get_author if sortby == 'byauthor' else lambda x: x - entries = [self.STANZA_SUBCATALOG_ENTRY.generate(title=gt(title), id=id, - what=what, updated=updated, count=c).render('xml').decode('utf-8') for id, - title, c in rdata] - next_link = '' - if next_offset > -1: - next_link = ('\n' - ) % (sortby, next_offset) - return self.STANZA.generate(subtitle=subtitle, data=entries, FM=self.db.FIELD_MAP, - updated=updated, id='urn:calibre:main', next_link=next_link).render('xml') - - def stanza_main(self, updated): - return self.STANZA_MAIN.generate(subtitle='', data=[], FM=self.db.FIELD_MAP, - updated=updated, id='urn:calibre:main').render('xml') - - @expose - def stanza(self, search=None, sortby=None, authorid=None, tagid=None, - seriesid=None, offset=0): - 'Feeds to read calibre books on a ipod with stanza.' - books = [] - updated = self.db.last_modified() - offset = int(offset) - cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) - cherrypy.response.headers['Content-Type'] = 'text/xml' - # Main feed - if not sortby and not search and not authorid and not tagid and not seriesid: - return self.stanza_main(updated) - if sortby in ('byseries', 'byauthor', 'bytag'): - return self.stanza_sortby_subcategory(updated, sortby, offset) - - # Get matching ids - if authorid: - authorid=int(authorid) - au = self.db.author_name(authorid) - ids = self.get_matches('authors', au) - elif tagid: - tagid=int(tagid) - ta = self.db.tag_name(tagid) - ids = self.get_matches('tags', ta) - elif seriesid: - seriesid=int(seriesid) - se = self.db.series_name(seriesid) - ids = self.get_matches('series', se) - else: - ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set() - record_list = list(iter(self.db)) - - FM = self.db.FIELD_MAP - # Sort the record list - if sortby == "bytitle" or authorid or tagid: - record_list.sort(lambda x, y: - cmp(title_sort(x[FM['title']]), - title_sort(y[FM['title']]))) - elif seriesid: - record_list.sort(lambda x, y: - cmp(x[FM['series_index']], - y[FM['series_index']])) - else: # Sort by date - record_list = reversed(record_list) - - - fmts = FM['formats'] - pat = re.compile(r'EPUB|PDB', re.IGNORECASE) - record_list = [x for x in record_list if x[FM['id']] in ids and - pat.search(x[fmts] if x[fmts] else '') is not None] - next_offset = offset + self.max_stanza_items - nrecord_list = record_list[offset:next_offset] - if next_offset >= len(record_list): - next_offset = -1 - - next_link = '' - if next_offset > -1: - q = ['offset=%d'%next_offset] - for x in ('search', 'sortby', 'authorid', 'tagid', 'seriesid'): - val = locals()[x] - if val is not None: - val = prepare_string_for_xml(unicode(val), True) - q.append('%s=%s'%(x, val)) - next_link = ('\n' - ) % '&'.join(q) - - for record in nrecord_list: - r = record[FM['formats']] - r = r.upper() if r else '' - - z = record[FM['authors']] - if not z: - z = _('Unknown') - authors = ' & '.join([i.replace('|', ',') for i in - z.split(',')]) - - # Setup extra description - extra = [] - rating = record[FM['rating']] - if rating > 0: - rating = ''.join(repeat('★', rating)) - extra.append('RATING: %s
'%rating) - tags = record[FM['tags']] - if tags: - extra.append('TAGS: %s
'%\ - prepare_string_for_xml(', '.join(tags.split(',')))) - series = record[FM['series']] - if series: - extra.append('SERIES: %s [%s]
'%\ - (prepare_string_for_xml(series), - fmt_sidx(float(record[FM['series_index']])))) - - fmt = 'epub' if 'EPUB' in r else 'pdb' - mimetype = guess_type('dummy.'+fmt)[0] - - # Create the sub-catalog, which is either a list of - # authors/tags/series or a list of books - data = dict( - record=record, - updated=updated, - authors=authors, - tags=tags, - series=series, - FM=FM, - extra='\n'.join(extra), - mimetype=mimetype, - fmt=fmt, - urn=record[FM['uuid']], - timestamp=strftime('%Y-%m-%dT%H:%M:%S+00:00', - record[FM['timestamp']]) - ) - books.append(self.STANZA_ENTRY.generate(**data)\ - .render('xml').decode('utf8')) - - return self.STANZA.generate(subtitle='', data=books, FM=FM, - next_link=next_link, updated=updated, id='urn:calibre:main').render('xml') - - - @expose - 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) - ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set() - ids = sorted(ids) - FM = self.db.FIELD_MAP - items = copy.deepcopy([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')) - - book, books = MarkupTemplate(self.MOBILE_BOOK), [] - for record in items[(start-1):(start-1)+num]: - if record[FM['formats']] is None: - record[FM['formats']] = '' - if record[FM['size']] is None: - record[FM['size']] = 0 - aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown') - authors = '|'.join([i.replace('|', ',') for i in aus.split(',')]) - record[FM['series_index']] = \ - fmt_sidx(float(record[FM['series_index']])) - ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[FM['timestamp']]), \ - strftime('%Y/%m/%d %H:%M:%S', record[FM['pubdate']]) - books.append(book.generate(r=record, authors=authors, timestamp=ts, - pubdate=pd, FM=FM).render('xml').decode('utf-8')) - updated = self.db.last_modified() - - cherrypy.response.headers['Content-Type'] = 'text/html; charset=utf-8' - cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) - - - url_base = "/mobile?search=" + search+";order="+order+";sort="+sort+";num="+str(num) - - return self.MOBILE.generate(books=books, start=start, updated=updated, - search=search, sort=sort, order=order, num=num, FM=FM, - total=len(ids), url_base=url_base).render('html') - - - @expose - def library(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' - ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set() - ids = sorted(ids) - FM = self.db.FIELD_MAP - items = copy.deepcopy([r for r in iter(self.db) if r[FM['id']] in ids]) - if sort is not None: - self.sort(items, sort, order) - - book, books = MarkupTemplate(self.BOOK), [] - for record in items[start:start+num]: - aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown') - authors = '|'.join([i.replace('|', ',') for i in aus.split(',')]) - record[FM['series_index']] = \ - fmt_sidx(float(record[FM['series_index']])) - ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[FM['timestamp']]), \ - strftime('%Y/%m/%d %H:%M:%S', record[FM['pubdate']]) - books.append(book.generate(r=record, authors=authors, timestamp=ts, - pubdate=pd, FM=FM).render('xml').decode('utf-8')) - updated = self.db.last_modified() - - cherrypy.response.headers['Content-Type'] = 'text/xml' - cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) - return self.LIBRARY.generate(books=books, start=start, updated=updated, - total=len(ids), FM=FM).render('xml') - - @expose - 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') - - # A better search would be great - want_mobile = self.MOBILE_UA.search(ua) is not None - if self.opts.develop and not want_mobile: - cherrypy.log('User agent: '+ua) - - if want_opds: - return self.stanza(search=kwargs.get('search', None), sortby=kwargs.get('sortby',None), authorid=kwargs.get('authorid',None), - tagid=kwargs.get('tagid',None), - seriesid=kwargs.get('seriesid',None), - offset=kwargs.get('offset', 0)) - - if want_mobile: - return self.mobile() - - return self.static('index.html') - - - @expose - def get(self, what, id, *args, **kwargs): - 'Serves files, covers, thumbnails from the calibre database' - try: - id = int(id) - except ValueError: - id = id.rpartition('_')[-1].partition('.')[0] - match = re.search(r'\d+', id) - if not match: - raise cherrypy.HTTPError(400, 'id:%s not an integer'%id) - id = int(match.group()) - if not self.db.has_id(id): - raise cherrypy.HTTPError(400, 'id:%d does not exist in database'%id) - if what == 'thumb': - return self.get_cover(id, thumbnail=True) - if what == 'cover': - return self.get_cover(id) - return self.get_format(id, what) - - @expose - def static(self, name): - 'Serves static content' - name = name.lower() - cherrypy.response.headers['Content-Type'] = { - 'js' : 'text/javascript', - 'css' : 'text/css', - 'png' : 'image/png', - 'gif' : 'image/gif', - 'html' : 'text/html', - '' : 'application/octet-stream', - }[name.rpartition('.')[-1].lower()] - cherrypy.response.headers['Last-Modified'] = self.last_modified(self.build_time) - path = P('content_server/'+name) - if not os.path.exists(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) - return open(path, 'rb').read() - -def start_threaded_server(db, opts): - server = LibraryServer(db, opts, embedded=True) - 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 option_parser(): - parser = config().option_parser('%prog '+ _('[options]\n\nStart the calibre content server.')) - 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.') - return parser - -def daemonize(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): - try: - pid = os.fork() - if pid > 0: - # exit first parent - sys.exit(0) - except OSError, 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, e: - print >>sys.stderr, "fork #2 failed: %d (%s)" % (e.errno, e.strerror) - sys.exit(1) - - # Redirect standard file descriptors. - si = file(stdin, 'r') - so = file(stdout, 'a+') - se = file(stderr, 'a+', 0) - os.dup2(si.fileno(), sys.stdin.fileno()) - os.dup2(so.fileno(), sys.stdout.fileno()) - os.dup2(se.fileno(), sys.stderr.fileno()) - - - -def main(args=sys.argv): - parser = option_parser() - opts, args = parser.parse_args(args) - if opts.daemonize and not iswindows: - daemonize() - if opts.pidfile is not None: - with open(opts.pidfile, 'wb') as f: - f.write(str(os.getpid())) - cherrypy.log.screen = True - from calibre.utils.config import prefs - if opts.with_library is None: - opts.with_library = prefs['library_path'] - db = LibraryDatabase2(opts.with_library) - server = LibraryServer(db, opts) - server.start() - return 0 - -if __name__ == '__main__': - sys.exit(main()) diff --git a/src/calibre/library/server/__init__.py b/src/calibre/library/server/__init__.py new file mode 100644 index 0000000000..9c092f6c2f --- /dev/null +++ b/src/calibre/library/server/__init__.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import os + +from calibre.utils.config import Config, StringConfig, config_dir + + +listen_on = '0.0.0.0' + + +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 automatically restarts on file changes and serves code files (html, css, js) from the file system instead of calibre\'s resource system.') + 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.')) + return c + diff --git a/src/calibre/library/server/base.py b/src/calibre/library/server/base.py new file mode 100644 index 0000000000..666ce52ffc --- /dev/null +++ b/src/calibre/library/server/base.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import os +import logging +from logging.handlers import RotatingFileHandler + +import cherrypy + +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.utils.mdns import publish as publish_zeroconf, \ + stop_server as stop_zeroconf, get_external_ip +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 + +class LibraryServer(ContentServer, MobileServer, XMLServer, OPDSServer): + + server_name = __appname__ + '/' + __version__ + + def __init__(self, db, opts, embedded=False, show_tracebacks=True): + self.db = db + for item in self.db: + item + break + self.opts = opts + self.embedded = embedded + self.max_cover_width, self.max_cover_height = \ + map(int, self.opts.max_cover.split('x')) + self.max_stanza_items = opts.max_opds_items + 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() + cherrypy.config.update({ + 'log.screen' : opts.develop, + 'engine.autoreload_on' : opts.develop, + 'tools.log_headers.on' : opts.develop, + '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 + }) + if embedded: + cherrypy.config.update({'engine.SIGHUP' : None, + 'engine.SIGTERM' : None,}) + self.config = {'global': { + 'tools.gzip.on' : True, + 'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/xml', 'text/javascript', 'text/css'], + }} + if opts.password: + self.config['/'] = { + 'tools.digest_auth.on' : True, + 'tools.digest_auth.realm' : (_('Password to access your calibre library. Username is ') + opts.username.strip()).encode('ascii', 'replace'), + 'tools.digest_auth.users' : {opts.username.strip():opts.password.strip()}, + } + + self.is_running = False + self.exception = None + + 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(self): + self.is_running = False + self.setup_loggers() + cherrypy.tree.mount(self, '', config=self.config) + try: + try: + cherrypy.engine.start() + except: + ip = get_external_ip() + if not ip or ip == '127.0.0.1': + raise + cherrypy.log('Trying to bind to single interface: '+ip) + cherrypy.config.update({'server.socket_host' : ip}) + cherrypy.engine.start() + + self.is_running = True + try: + publish_zeroconf('Books in calibre', '_stanza._tcp', + self.opts.port, {'path':'/stanza'}) + except: + import traceback + cherrypy.log.error('Failed to start BonJour:') + cherrypy.log.error(traceback.format_exc()) + cherrypy.engine.block() + except Exception, e: + self.exception = e + finally: + self.is_running = False + try: + stop_zeroconf() + except: + import traceback + cherrypy.log.error('Failed to stop BonJour:') + cherrypy.log.error(traceback.format_exc()) + + def exit(self): + try: + cherrypy.engine.exit() + finally: + cherrypy.server.httpserver = None + + diff --git a/src/calibre/library/server/content.py b/src/calibre/library/server/content.py new file mode 100644 index 0000000000..d1a695cee1 --- /dev/null +++ b/src/calibre/library/server/content.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import re, os, cStringIO, operator + +import cherrypy +try: + from PIL import Image as PILImage + PILImage +except ImportError: + import Image as PILImage + +from calibre import fit_image, guess_type +from calibre.utils.date import fromtimestamp +from calibre.library.server.utils import expose + +class ContentServer(object): + + ''' + Handles actually serving content files/covers. Also has + a few utility methods. + ''' + + # Utility methods {{{ + def last_modified(self, updated): + ''' + Generates a local independent, english timestamp from a datetime + object + ''' + 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 = field.lower().strip() + if field == 'author': + field = 'authors' + if field == 'date': + field = 'timestamp' + if field not in ('title', 'authors', 'rating', 'timestamp', 'tags', 'size', 'series'): + raise cherrypy.HTTPError(400, '%s is not a valid sort field'%field) + cmpf = cmp if field in ('rating', 'size', 'timestamp') else \ + lambda x, y: cmp(x.lower() if x else '', y.lower() if y else '') + if field == 'series': + items.sort(cmp=self.seriescmp, reverse=not order) + else: + lookup = 'sort' if field == 'title' else field + field = self.db.FIELD_MAP[lookup] + getter = operator.itemgetter(field) + items.sort(cmp=lambda x, y: cmpf(getter(x), getter(y)), reverse=not order) + + def seriescmp(self, x, y): + si = self.db.FIELD_MAP['series'] + try: + ans = cmp(x[si].lower(), y[si].lower()) + except AttributeError: # Some entries may be None + ans = cmp(x[si], y[si]) + if ans != 0: return ans + return cmp(x[self.db.FIELD_MAP['series_index']], y[self.db.FIELD_MAP['series_index']]) + # }}} + + + @expose + def get(self, what, id, *args, **kwargs): + 'Serves files, covers, thumbnails from the calibre database' + try: + id = int(id) + except ValueError: + id = id.rpartition('_')[-1].partition('.')[0] + match = re.search(r'\d+', id) + if not match: + raise cherrypy.HTTPError(400, 'id:%s not an integer'%id) + id = int(match.group()) + if not self.db.has_id(id): + raise cherrypy.HTTPError(400, 'id:%d does not exist in database'%id) + if what == 'thumb': + return self.get_cover(id, thumbnail=True) + if what == 'cover': + return self.get_cover(id) + return self.get_format(id, what) + + @expose + def static(self, name): + 'Serves static content' + name = name.lower() + cherrypy.response.headers['Content-Type'] = { + 'js' : 'text/javascript', + 'css' : 'text/css', + 'png' : 'image/png', + 'gif' : 'image/gif', + 'html' : 'text/html', + '' : 'application/octet-stream', + }[name.rpartition('.')[-1].lower()] + cherrypy.response.headers['Last-Modified'] = self.last_modified(self.build_time) + path = P('content_server/'+name) + if not os.path.exists(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) + return open(path, 'rb').read() + + @expose + 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') + + # A better search would be great + want_mobile = self.MOBILE_UA.search(ua) is not None + if self.opts.develop and not want_mobile: + cherrypy.log('User agent: '+ua) + + if want_opds: + return self.stanza(search=kwargs.get('search', None), sortby=kwargs.get('sortby',None), authorid=kwargs.get('authorid',None), + tagid=kwargs.get('tagid',None), + seriesid=kwargs.get('seriesid',None), + offset=kwargs.get('offset', 0)) + + if want_mobile: + return self.mobile() + + return self.static('index.html') + + + + # Actually get content from the database {{{ + def get_cover(self, id, thumbnail=False): + cover = self.db.cover(id, index_is_id=True, as_file=False) + if cover is None: + cover = self.default_cover + cherrypy.response.headers['Content-Type'] = 'image/jpeg' + cherrypy.response.timeout = 3600 + path = getattr(cover, 'name', False) + updated = fromtimestamp(os.stat(path).st_mtime) if path and \ + os.access(path, os.R_OK) else self.build_time + cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) + try: + f = cStringIO.StringIO(cover) + try: + im = PILImage.open(f) + except IOError: + raise cherrypy.HTTPError(404, 'No valid cover found') + width, height = im.size + scaled, width, height = fit_image(width, height, + 60 if thumbnail else self.max_cover_width, + 80 if thumbnail else self.max_cover_height) + if not scaled: + return cover + im = im.resize((int(width), int(height)), PILImage.ANTIALIAS) + of = cStringIO.StringIO() + im.convert('RGB').save(of, 'JPEG') + return of.getvalue() + except Exception, err: + import traceback + cherrypy.log.error('Failed to generate cover:') + cherrypy.log.error(traceback.print_exc()) + raise cherrypy.HTTPError(404, 'Failed to generate cover: %s'%err) + + def get_format(self, id, format): + format = format.upper() + 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)) + if format == 'EPUB': + from tempfile import TemporaryFile + from calibre.ebooks.metadata.meta import set_metadata + raw = fmt.read() + fmt = TemporaryFile() + fmt.write(raw) + fmt.seek(0) + set_metadata(fmt, self.db.get_metadata(id, index_is_id=True), + 'epub') + fmt.seek(0) + mt = guess_type('dummy.'+format.lower())[0] + if mt is None: + mt = 'application/octet-stream' + cherrypy.response.headers['Content-Type'] = mt + cherrypy.response.timeout = 3600 + path = getattr(fmt, 'name', None) + if path and os.path.exists(path): + updated = fromtimestamp(os.stat(path).st_mtime) + cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) + return fmt.read() + # }}} + + diff --git a/src/calibre/library/server/main.py b/src/calibre/library/server/main.py new file mode 100644 index 0000000000..5ca82c6b98 --- /dev/null +++ b/src/calibre/library/server/main.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import os, sys +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 +import cherrypy + +def start_threaded_server(db, opts): + server = LibraryServer(db, opts, embedded=True) + 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 option_parser(): + parser = config().option_parser('%prog '+ _('[options]\n\nStart the calibre content server.')) + 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.') + return parser + +def daemonize(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): + try: + pid = os.fork() + if pid > 0: + # exit first parent + sys.exit(0) + except OSError, 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, e: + print >>sys.stderr, "fork #2 failed: %d (%s)" % (e.errno, e.strerror) + sys.exit(1) + + # Redirect standard file descriptors. + si = file(stdin, 'r') + so = file(stdout, 'a+') + se = file(stderr, 'a+', 0) + os.dup2(si.fileno(), sys.stdin.fileno()) + os.dup2(so.fileno(), sys.stdout.fileno()) + os.dup2(se.fileno(), sys.stderr.fileno()) + + + +def main(args=sys.argv): + from calibre.library.database2 import LibraryDatabase2 + parser = option_parser() + opts, args = parser.parse_args(args) + if opts.daemonize and not iswindows: + daemonize() + if opts.pidfile is not None: + with open(opts.pidfile, 'wb') as f: + f.write(str(os.getpid())) + cherrypy.log.screen = True + from calibre.utils.config import prefs + if opts.with_library is None: + opts.with_library = prefs['library_path'] + db = LibraryDatabase2(opts.with_library) + server = LibraryServer(db, opts) + server.start() + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/calibre/library/server/mobile.py b/src/calibre/library/server/mobile.py new file mode 100644 index 0000000000..9bec6cce35 --- /dev/null +++ b/src/calibre/library/server/mobile.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import re, copy +import __builtin__ + +import cherrypy + +from calibre.utils.genshi.template import MarkupTemplate +from calibre.library.server.utils import strftime, expose +from calibre.ebooks.metadata import fmt_sidx + +# Templates {{{ +MOBILE_BOOK = '''\ + + + + + + + ${format.lower()}  + + ${r[FM['title']]}${(' ['+r[FM['series']]+'-'+r[FM['series_index']]+']') if r[FM['series']] else ''} by ${authors} - ${r[FM['size']]/1024}k - ${r[FM['publisher']] if r[FM['publisher']] else ''} ${pubdate} ${'['+r[FM['tags']]+']' if r[FM['tags']] else ''} + + +''' + +MOBILE = MarkupTemplate('''\ + + + + + + + + + +
+ + + ${Markup(book)} + +
+ + +''') + +# }}} + +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)') + + @expose + 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) + ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set() + ids = sorted(ids) + FM = self.db.FIELD_MAP + items = copy.deepcopy([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')) + + book, books = MarkupTemplate(MOBILE_BOOK), [] + for record in items[(start-1):(start-1)+num]: + if record[FM['formats']] is None: + record[FM['formats']] = '' + if record[FM['size']] is None: + record[FM['size']] = 0 + aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown') + authors = '|'.join([i.replace('|', ',') for i in aus.split(',')]) + record[FM['series_index']] = \ + fmt_sidx(float(record[FM['series_index']])) + ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[FM['timestamp']]), \ + strftime('%Y/%m/%d %H:%M:%S', record[FM['pubdate']]) + books.append(book.generate(r=record, authors=authors, timestamp=ts, + pubdate=pd, FM=FM).render('xml').decode('utf-8')) + updated = self.db.last_modified() + + cherrypy.response.headers['Content-Type'] = 'text/html; charset=utf-8' + cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) + + + url_base = "/mobile?search=" + search+";order="+order+";sort="+sort+";num="+str(num) + + return MOBILE.generate(books=books, start=start, updated=updated, + search=search, sort=sort, order=order, num=num, FM=FM, + total=len(ids), url_base=url_base).render('html') + + diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py new file mode 100644 index 0000000000..f7a7679813 --- /dev/null +++ b/src/calibre/library/server/opds.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import re +from itertools import repeat + +import cherrypy + +from calibre.utils.genshi.template import MarkupTemplate +from calibre.library.server.utils import strftime, expose +from calibre.ebooks.metadata import fmt_sidx, title_sort +from calibre import guess_type, prepare_string_for_xml + +# Templates {{{ + +STANZA_ENTRY=MarkupTemplate('''\ + + ${record[FM['title']]} + urn:calibre:${urn} + ${authors} + ${timestamp} + + + + +
${Markup(extra)}${record[FM['comments']]}
+
+
+''') + +STANZA_SUBCATALOG_ENTRY=MarkupTemplate('''\ + + ${title} + urn:calibre:${id} + ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} + + ${count} books + +''') + +STANZA = MarkupTemplate('''\ + + + calibre Library + $id + ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} + + ${Markup(next_link)} + + calibre + http://calibre-ebook.com + + + ${subtitle} + + + ${Markup(entry)} + + +''') + +STANZA_MAIN = MarkupTemplate('''\ + + + calibre Library + $id + ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} + + + calibre + http://calibre-ebook.com + + + ${subtitle} + + + By Author + urn:uuid:fc000fa0-8c23-11de-a31d-0002a5d5c51b + ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} + + Books sorted by Author + + + By Title + urn:uuid:1df4fe40-8c24-11de-b4c6-0002a5d5c51b + ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} + + Books sorted by Title + + + By Newest + urn:uuid:3c6d4940-8c24-11de-a4d7-0002a5d5c51b + ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} + + Books sorted by Date + + + By Tag + urn:uuid:824921e8-db8a-4e61-7d38-f1ce41502853 + ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} + + Books sorted by Tags + + + By Series + urn:uuid:512a5e50-a88f-f6b8-82aa-8f129c719f61 + ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} + + Books sorted by Series + + +''') + +# }}} + +class OPDSServer(object): + + def get_matches(self, location, query): + base = self.db.data.get_matches(location, query) + epub = self.db.data.get_matches('format', '=epub') + pdb = self.db.data.get_matches('format', '=pdb') + return base.intersection(epub.union(pdb)) + + def stanza_sortby_subcategory(self, updated, sortby, offset): + pat = re.compile(r'\(.*\)') + + def clean_author(x): + return pat.sub('', x).strip() + + def author_cmp(x, y): + x = x if ',' in x else clean_author(x).rpartition(' ')[-1] + y = y if ',' in y else clean_author(y).rpartition(' ')[-1] + return cmp(x.lower(), y.lower()) + + def get_author(x): + pref, ___, suff = clean_author(x).rpartition(' ') + return suff + (', '+pref) if pref else suff + + + what, subtitle = sortby[2:], '' + if sortby == 'byseries': + data = self.db.all_series() + data = [(x[0], x[1], len(self.get_matches('series', '='+x[1]))) for x in data] + subtitle = 'Books by series' + elif sortby == 'byauthor': + data = self.db.all_authors() + data = [(x[0], x[1], len(self.get_matches('authors', '='+x[1]))) for x in data] + subtitle = 'Books by author' + elif sortby == 'bytag': + data = self.db.all_tags2() + data = [(x[0], x[1], len(self.get_matches('tags', '='+x[1]))) for x in data] + subtitle = 'Books by tag' + fcmp = author_cmp if sortby == 'byauthor' else cmp + data = [x for x in data if x[2] > 0] + data.sort(cmp=lambda x, y: fcmp(x[1], y[1])) + next_offset = offset + self.max_stanza_items + rdata = data[offset:next_offset] + if next_offset >= len(data): + next_offset = -1 + gt = get_author if sortby == 'byauthor' else lambda x: x + entries = [STANZA_SUBCATALOG_ENTRY.generate(title=gt(title), id=id, + what=what, updated=updated, count=c).render('xml').decode('utf-8') for id, + title, c in rdata] + next_link = '' + if next_offset > -1: + next_link = ('\n' + ) % (sortby, next_offset) + return STANZA.generate(subtitle=subtitle, data=entries, FM=self.db.FIELD_MAP, + updated=updated, id='urn:calibre:main', next_link=next_link).render('xml') + + def stanza_main(self, updated): + return STANZA_MAIN.generate(subtitle='', data=[], FM=self.db.FIELD_MAP, + updated=updated, id='urn:calibre:main').render('xml') + + @expose + def stanza(self, search=None, sortby=None, authorid=None, tagid=None, + seriesid=None, offset=0): + 'Feeds to read calibre books on a ipod with stanza.' + books = [] + updated = self.db.last_modified() + offset = int(offset) + cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) + cherrypy.response.headers['Content-Type'] = 'text/xml' + # Main feed + if not sortby and not search and not authorid and not tagid and not seriesid: + return self.stanza_main(updated) + if sortby in ('byseries', 'byauthor', 'bytag'): + return self.stanza_sortby_subcategory(updated, sortby, offset) + + # Get matching ids + if authorid: + authorid=int(authorid) + au = self.db.author_name(authorid) + ids = self.get_matches('authors', au) + elif tagid: + tagid=int(tagid) + ta = self.db.tag_name(tagid) + ids = self.get_matches('tags', ta) + elif seriesid: + seriesid=int(seriesid) + se = self.db.series_name(seriesid) + ids = self.get_matches('series', se) + else: + ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set() + record_list = list(iter(self.db)) + + FM = self.db.FIELD_MAP + # Sort the record list + if sortby == "bytitle" or authorid or tagid: + record_list.sort(lambda x, y: + cmp(title_sort(x[FM['title']]), + title_sort(y[FM['title']]))) + elif seriesid: + record_list.sort(lambda x, y: + cmp(x[FM['series_index']], + y[FM['series_index']])) + else: # Sort by date + record_list = reversed(record_list) + + + fmts = FM['formats'] + pat = re.compile(r'EPUB|PDB', re.IGNORECASE) + record_list = [x for x in record_list if x[FM['id']] in ids and + pat.search(x[fmts] if x[fmts] else '') is not None] + next_offset = offset + self.max_stanza_items + nrecord_list = record_list[offset:next_offset] + if next_offset >= len(record_list): + next_offset = -1 + + next_link = '' + if next_offset > -1: + q = ['offset=%d'%next_offset] + for x in ('search', 'sortby', 'authorid', 'tagid', 'seriesid'): + val = locals()[x] + if val is not None: + val = prepare_string_for_xml(unicode(val), True) + q.append('%s=%s'%(x, val)) + next_link = ('\n' + ) % '&'.join(q) + + for record in nrecord_list: + r = record[FM['formats']] + r = r.upper() if r else '' + + z = record[FM['authors']] + if not z: + z = _('Unknown') + authors = ' & '.join([i.replace('|', ',') for i in + z.split(',')]) + + # Setup extra description + extra = [] + rating = record[FM['rating']] + if rating > 0: + rating = ''.join(repeat('★', rating)) + extra.append('RATING: %s
'%rating) + tags = record[FM['tags']] + if tags: + extra.append('TAGS: %s
'%\ + prepare_string_for_xml(', '.join(tags.split(',')))) + series = record[FM['series']] + if series: + extra.append('SERIES: %s [%s]
'%\ + (prepare_string_for_xml(series), + fmt_sidx(float(record[FM['series_index']])))) + + fmt = 'epub' if 'EPUB' in r else 'pdb' + mimetype = guess_type('dummy.'+fmt)[0] + + # Create the sub-catalog, which is either a list of + # authors/tags/series or a list of books + data = dict( + record=record, + updated=updated, + authors=authors, + tags=tags, + series=series, + FM=FM, + extra='\n'.join(extra), + mimetype=mimetype, + fmt=fmt, + urn=record[FM['uuid']], + timestamp=strftime('%Y-%m-%dT%H:%M:%S+00:00', + record[FM['timestamp']]) + ) + books.append(STANZA_ENTRY.generate(**data)\ + .render('xml').decode('utf8')) + + return STANZA.generate(subtitle='', data=books, FM=FM, + next_link=next_link, updated=updated, id='urn:calibre:main').render('xml') + + + + diff --git a/src/calibre/library/server/utils.py b/src/calibre/library/server/utils.py new file mode 100644 index 0000000000..1732da540c --- /dev/null +++ b/src/calibre/library/server/utils.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +from calibre import strftime as _strftime +from calibre.utils.date import now as nowf + + +def expose(func): + import cherrypy + + def do(self, *args, **kwargs): + dict.update(cherrypy.response.headers, {'Server':self.server_name}) + if not self.embedded: + self.db.check_if_modified() + return func(self, *args, **kwargs) + + return cherrypy.expose(do) + +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()) + + diff --git a/src/calibre/library/server/xml.py b/src/calibre/library/server/xml.py new file mode 100644 index 0000000000..e9f9a02548 --- /dev/null +++ b/src/calibre/library/server/xml.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import copy, __builtin__ + +import cherrypy + +from calibre.utils.genshi.template import MarkupTemplate +from calibre.library.server.utils import strftime, expose +from calibre.ebooks.metadata import fmt_sidx + +# Templates {{{ +BOOK = '''\ +${r[FM['comments']] if r[FM['comments']] else ''} + +''' + + +LIBRARY = MarkupTemplate('''\ + + + + ${Markup(book)} + + +''') + +# }}} + +class XMLServer(object): + 'Serves XML and the Ajax based HTML frontend' + + @expose + def library(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' + ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set() + ids = sorted(ids) + FM = self.db.FIELD_MAP + items = copy.deepcopy([r for r in iter(self.db) if r[FM['id']] in ids]) + if sort is not None: + self.sort(items, sort, order) + + book, books = MarkupTemplate(BOOK), [] + for record in items[start:start+num]: + aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown') + authors = '|'.join([i.replace('|', ',') for i in aus.split(',')]) + record[FM['series_index']] = \ + fmt_sidx(float(record[FM['series_index']])) + ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[FM['timestamp']]), \ + strftime('%Y/%m/%d %H:%M:%S', record[FM['pubdate']]) + books.append(book.generate(r=record, authors=authors, timestamp=ts, + pubdate=pd, FM=FM).render('xml').decode('utf-8')) + updated = self.db.last_modified() + + cherrypy.response.headers['Content-Type'] = 'text/xml' + cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) + return LIBRARY.generate(books=books, start=start, updated=updated, + total=len(ids), FM=FM).render('xml') + + + + diff --git a/src/calibre/linux.py b/src/calibre/linux.py index 331783c775..ed806d58ac 100644 --- a/src/calibre/linux.py +++ b/src/calibre/linux.py @@ -18,7 +18,7 @@ entry_points = { 'ebook-convert = calibre.ebooks.conversion.cli:main', 'markdown-calibre = calibre.ebooks.markdown.markdown:main', 'web2disk = calibre.web.fetch.simple:main', - 'calibre-server = calibre.library.server:main', + 'calibre-server = calibre.library.server.main:main', 'lrf2lrs = calibre.ebooks.lrf.lrfparser:main', 'lrs2lrf = calibre.ebooks.lrf.lrs.convert_from:main', 'librarything = calibre.ebooks.metadata.library_thing:main',