From 7bc0b414cbf38282db0bb9cd5ac56bab6ee72d4a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 31 Oct 2008 20:33:56 -0700 Subject: [PATCH] IGN:Initial implementation of the calibre content server --- src/calibre/__init__.py | 6 +- src/calibre/library/database2.py | 28 +++- src/calibre/library/server.py | 278 ++++++++++++++++++++----------- 3 files changed, 206 insertions(+), 106 deletions(-) diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py index ea0cde9a2c..cbacf75271 100644 --- a/src/calibre/__init__.py +++ b/src/calibre/__init__.py @@ -2,7 +2,7 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import sys, os, re, logging, time, subprocess, atexit +import sys, os, re, logging, time, subprocess, atexit, mimetypes from htmlentitydefs import name2codepoint from math import floor from logging import Formatter @@ -16,6 +16,10 @@ from calibre.constants import iswindows, isosx, islinux, isfrozen, \ win32event, win32api, winerror, fcntl import mechanize +mimetypes.add_type('application/epub+zip', '.epub') +mimetypes.add_type('text/x-sony-bbeb+xml', '.lrs') +mimetypes.add_type('application/x-sony-bbeb', '.lrf') + def unicode_path(path, abs=False): if not isinstance(path, unicode): path = path.decode(sys.getfilesystemencoding()) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index ad203ecf4a..f1a61f122d 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -589,13 +589,22 @@ class LibraryDatabase2(LibraryDatabase): ans.append(format) return ','.join(ans) + def has_format(self, index, format, index_is_id=False): + id = index if index_is_id else self.id(index) + name = self.conn.get('SELECT name FROM data WHERE book=? AND format=?', (id, format), all=False) + if name: + path = os.path.join(self.library_path, self.path(id, index_is_id=True)) + format = ('.' + format.lower()) if format else '' + path = os.path.join(path, name+format) + return os.access(path, os.R_OK|os.W_OK) + return False def format_abspath(self, index, format, index_is_id=False): 'Return absolute path to the ebook file of format `format`' id = index if index_is_id else self.id(index) - path = os.path.join(self.library_path, self.path(id, index_is_id=True)) name = self.conn.get('SELECT name FROM data WHERE book=? AND format=?', (id, format), all=False) if name: + path = os.path.join(self.library_path, self.path(id, index_is_id=True)) format = ('.' + format.lower()) if format else '' path = os.path.join(path, name+format) if os.access(path, os.R_OK|os.W_OK): @@ -612,7 +621,8 @@ class LibraryDatabase2(LibraryDatabase): if path is not None: f = open(path, mode) return f if as_file else f.read() - self.remove_format(id, format, index_is_id=True) + if self.has_format(index, format, index_is_id): + self.remove_format(id, format, index_is_id=True) def add_format(self, index, format, stream, index_is_id=False, path=None): id = index if index_is_id else self.id(index) @@ -982,6 +992,14 @@ class LibraryDatabase2(LibraryDatabase): progress.hide() + def __iter__(self): + if len(self.data) == 0: + self.refresh('timestamp', True) + for record in self.data: + if record is not None: + yield record + + def get_data_as_dict(self, prefix=None, authors_as_string=False): ''' Return all metadata stored in the database as a dict. Includes paths to @@ -994,11 +1012,7 @@ class LibraryDatabase2(LibraryDatabase): prefix = self.library_path FIELDS = set(['title', 'authors', 'publisher', 'rating', 'timestamp', 'size', 'tags', 'comments', 'series', 'series_index', 'isbn']) data = [] - if len(self.data) == 0: - self.refresh('timestamp', True) - for record in self.data: - if record is None: - continue + for record in iter(self): x = {} for field in FIELDS: x[field] = record[field] diff --git a/src/calibre/library/server.py b/src/calibre/library/server.py index 37b799832b..11eb443939 100644 --- a/src/calibre/library/server.py +++ b/src/calibre/library/server.py @@ -7,96 +7,182 @@ __docformat__ = 'restructuredtext en' HTTP server for remote access to the calibre database. ''' -import sys, logging, re, SocketServer, gzip, cStringIO -from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +import sys, textwrap, cStringIO, mimetypes +import cherrypy +from PIL import Image -from calibre.constants import __version__ +from calibre.constants import __version__, __appname__ from calibre.utils.config import StringConfig, Config -from calibre import ColoredFormatter +from calibre.utils.genshi.template import MarkupTemplate +from calibre import fit_image -class Server(SocketServer.ThreadingMixIn, HTTPServer): - def __init__(self, opts, db): +def expose(func): + + def do(self, *args, **kwargs): + dict.update(cherrypy.response.headers, {'Server':self.server_name}) + return func(self, *args, **kwargs) + + return cherrypy.expose(do) + +class LibraryServer(object): + + server_name = __appname__ + '/' + __version__ + + BOOK = textwrap.dedent('''\ + ${r[8] if r[8] else ''} + ''') + + LIBRARY = MarkupTemplate(textwrap.dedent('''\ + + + + ${Markup(book)} + + + ''')) + + STANZA_ENTRY=MarkupTemplate(textwrap.dedent('''\ + + ${record['title']} + urn:calibre:${record['id']} + ${authors} + ${record['timestamp'].strftime('%Y-%m-%dT%H:%M:%S+0000')} + + + + +
${record['comments']}
+
+
+ ''')) + + STANZA = MarkupTemplate(textwrap.dedent('''\ + + + calibre Library + + calibre + http://calibre.kovidgoyal.net + + + ${subtitle} + + + ${Markup(entry)} + + + ''')) + + + def __init__(self, db, opts): self.db = db - HTTPServer.__init__(self, ('', opts.port), DBHandler) - - def serve_forever(self): - logging.getLogger('calibre.server').info('calibre-server starting...') - HTTPServer.serve_forever(self) - - -class DBHandler(BaseHTTPRequestHandler): - - server_version = 'calibre/'+__version__ - protocol_version = 'HTTP/1.0' - cover_request = re.compile(r'/(\d+)/cover', re.IGNORECASE) - thumbnail_request = re.compile(r'/(\d+)/thumb', re.IGNORECASE) - fmt_request = re.compile(r'/(\d+)/([a-z0-9]+)', re.IGNORECASE) - - - def __init__(self, request, client_address, server, *args, **kwargs): - self.l = logging.getLogger('calibre.server') - self.db = server.db - BaseHTTPRequestHandler.__init__(self, request, client_address, server, *args, **kwargs) + for item in self.db: + item + break + self.opts = opts + cherrypy.config.update({ + 'server.socket_port': opts.port, + 'server.socket_timeout': opts.timeout, #seconds + 'server.thread_pool': opts.thread_pool, # number of threads + }) + self.config = textwrap.dedent('''\ + [global] + engine.autoreload_on = %(autoreload)s + tools.gzip.on = True + tools.gzip.mime_types = ['text/html', 'text/plain', 'text/xml'] + ''')%dict(autoreload=opts.develop) + def to_xml(self): + books = [] + book = MarkupTemplate(self.BOOK) + for record in iter(self.db): + authors = ' & '.join([i.replace('|', ',') for i in record[2].split(',')]) + books.append(book.generate(r=record, authors=authors).render('xml').decode('utf-8')) + return self.LIBRARY.generate(books=books).render('xml') - def log_message(self, fmt, *args): - self.l.info("%s - - [%s] %s\n" % - (self.address_string(), - self.log_date_time_string(), - fmt%args)) - - def log_error(self, fmt, *args): - self.l.error("%s - - [%s] %s\n" % - (self.address_string(), - self.log_date_time_string(), - fmt%args)) - - def do_GET(self): - cover = self.cover_request.match(self.path) - thumb = self.thumbnail_request.match(self.path) - fmt = self.fmt_request.match(self.path) - if self.path == '/': - self.send_index() - elif self.path == '/stanza.atom': - self.send_stanza_index() - elif self.path == '/library': - self.send_library() - elif thumb: - self.send_cover(int(thumb.group(1)), thumbnail=True) - elif cover: - self.send_cover(int(cover.group(1))) - elif fmt: - self.send_format(int(fmt.group(1)), fmt.group(2).upper()) - elif self.path == '/help': - self.send_help() - - self.send_error(400, 'Bad request. Try /help for usage.') - - def compress(buf): - zbuf = cStringIO.StringIO() - zfile = gzip.GzipFile(mode = 'wb', fileobj = zbuf, compresslevel = 9) - zfile.write(buf) - zfile.close() - return zbuf.getvalue() - + def start(self): + cherrypy.quickstart(self, config=cStringIO.StringIO(self.config)) - def send_help(self): - self.send_error(501, 'Not Implemented') + def get_cover(self, id, thumbnail=False): + cover = self.db.cover(id, index_is_id=True, as_file=True) + if cover is None: + raise cherrypy.HTTPError(404, 'no cover available for id: %d'%id) + cherrypy.response.headers['Content-Type'] = 'image/jpeg' + if not thumbnail: + return cover.read() + try: + im = Image.open(cover) + width, height = im.size + scaled, width, height = fit_image(width, height, 80, 60) + if not scaled: + return cover.read() + im.thumbnail((width, height)) + o = cStringIO.StringIO() + im.save(o, 'JPEG') + return o.getvalue() + except Exception, err: + raise cherrypy.HTTPError(404, 'failed to generate thumbnail: %s'%err) + + def get_format(self, id, format): + format = format.upper() + fmt = self.db.format(id, format, index_is_id=True) + if fmt is None: + raise cherrypy.HTTPError(404, 'book: %d does not have format: %s'%(id, format)) + mt = mimetypes.guess_type('dummy.'+format.lower())[0] + if mt is None: + mt = 'application/octet-stream' + cherrypy.response.headers['Content-Type'] = mt + return fmt + + @expose + def stanza(self): + cherrypy.response.headers['Content-Type'] = 'text/xml' + books = [] + for record in iter(self.db): + authors = ' & '.join([i.replace('|', ',') for i in record[2].split(',')]) + books.append(self.STANZA_ENTRY.generate(authors=authors, + record=record, + port=self.opts.port, + server=self.opts.hostname, + ).render('xml').decode('utf8')) + return self.STANZA.generate(subtitle='', data=books).render('xml') - def send_index(self): - self.send_error(501, 'Not Implemented') - - def send_stanza_index(self): - self.send_error(501, 'Not Implemented') - - def send_library(self): - self.send_error(501, 'Not Implemented') + @expose + def library(self): + cherrypy.response.headers['Content-Type'] = 'text/xml' + return self.to_xml() - def send_cover(self, id, thumbnail=False): - self.send_error(501, 'Not Implemented') - - def send_format(self, id, fmt): - self.send_error(501, 'Not Implemented') + @expose + def index(self): + return 'Hello, World!' + + @expose + def get(self, what, id): + try: + id = int(id) + except ValueError: + raise cherrypy.HTTPError(400, 'id:%s not an integer'%id) + 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) def config(defaults=None): desc=_('Settings to control the calibre content server') @@ -104,8 +190,15 @@ def config(defaults=None): c.add_opt('port', ['-p', '--port'], default=8080, help=_('The port on which to listen. Default is %default')) - c.add_opt('debug', ['--debug'], default=False, - help=_('Detailed logging')) + 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('hostname', ['--hostname'], default='localhost', + help=_('The hostname of the machine the server is running on. Used when generating the stanza feeds. Default is %default')) + + c.add_opt('develop', ['--develop'], default=False, + help='Development mode. Server automatically restarts on file changes.') return c def option_parser(): @@ -114,23 +207,12 @@ def option_parser(): def main(args=sys.argv): parser = option_parser() opts, args = parser.parse_args(args) - l = logging.getLogger('calibre.server') - l.setLevel(logging.DEBUG if opts.debug else logging.INFO) - l.addHandler(logging.StreamHandler(sys.stdout)) - l.handlers[-1].setLevel(logging.DEBUG if opts.debug else logging.INFO) - formatter = ColoredFormatter('%(levelname)s: %(message)s') - l.handlers[-1].setFormatter(formatter) - + cherrypy.log.screen = True from calibre.utils.config import prefs from calibre.library.database2 import LibraryDatabase2 - db = LibraryDatabase2(prefs['library_path']) - try: - print 'Starting server...' - s = Server(opts, db) - s.serve_forever() - except KeyboardInterrupt: - print 'Server interrupted' - s.socket.close() + db = LibraryDatabase2(prefs['library_path'], row_factory=True) + server = LibraryServer(db, opts) + server.start() return 0 if __name__ == '__main__':