Kovid Goyal ff7969051e IGN:...
2009-03-14 18:39:46 -07:00

415 lines
17 KiB
Python

#!/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
from itertools import repeat
from logging.handlers import RotatingFileHandler
from datetime import datetime
from threading import Thread
import cherrypy
from PyQt4.Qt import QImage, QApplication, QByteArray, Qt, QBuffer
from calibre.constants import __version__, __appname__
from calibre.utils.genshi.template import MarkupTemplate
from calibre import fit_image, guess_type
from calibre.resources import jquery, server_resources, build_time
from calibre.library import server_config as config
from calibre.library.database2 import LibraryDatabase2, FIELD_MAP
from calibre.utils.config import config_dir
from calibre.utils.mdns import publish as publish_zeroconf, \
stop_server as stop_zeroconf
build_time = datetime.strptime(build_time, '%d %m %Y %H%M%S')
server_resources['jquery.js'] = jquery
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)
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('''\
<book xmlns:py="http://genshi.edgewall.org/"
id="${r[0]}"
title="${r[1]}"
sort="${r[11]}"
author_sort="${r[12]}"
authors="${authors}"
rating="${r[4]}"
timestamp="${r[5].strftime('%Y/%m/%d %H:%M:%S')}"
size="${r[6]}"
isbn="${r[14] if r[14] else ''}"
formats="${r[13] if r[13] else ''}"
series = "${r[9] if r[9] else ''}"
series_index="${r[10]}"
tags="${r[7] if r[7] else ''}"
publisher="${r[3] if r[3] else ''}">${r[8] if r[8] else ''}
</book>
''')
LIBRARY = MarkupTemplate(textwrap.dedent('''\
<?xml version="1.0" encoding="utf-8"?>
<library xmlns:py="http://genshi.edgewall.org/" start="$start" num="${len(books)}" total="$total" updated="${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}">
<py:for each="book in books">
${Markup(book)}
</py:for>
</library>
'''))
STANZA_ENTRY=MarkupTemplate(textwrap.dedent('''\
<entry xmlns:py="http://genshi.edgewall.org/">
<title>${record[FM['title']]}</title>
<id>urn:calibre:${record[FM['id']]}</id>
<author><name>${authors}</name></author>
<updated>${record[FM['timestamp']].strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
<link type="${mimetype}" href="/get/${fmt}/${record[FM['id']]}" />
<link rel="x-stanza-cover-image" type="image/jpeg" href="/get/cover/${record[FM['id']]}" />
<link rel="x-stanza-cover-image-thumbnail" type="image/jpeg" href="/get/thumb/${record[FM['id']]}" />
<content type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml" style="text-align: center">${Markup(extra)}${record[FM['comments']]}</div>
</content>
</entry>
'''))
STANZA = MarkupTemplate(textwrap.dedent('''\
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:py="http://genshi.edgewall.org/">
<title>calibre Library</title>
<id>$id</id>
<updated>${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
<author>
<name>calibre</name>
<uri>http://calibre.kovidgoyal.net</uri>
</author>
<subtitle>
${subtitle}
</subtitle>
<py:for each="entry in data">
${Markup(entry)}
</py:for>
</feed>
'''))
def __init__(self, db, opts, embedded=False, show_tracebacks=True):
self.db = db
for item in self.db:
item
break
self.opts = opts
self.max_cover_width, self.max_cover_height = \
map(int, self.opts.max_cover.split('x'))
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' : '0.0.0.0',
'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:
cherrypy.engine.start()
self.is_running = True
publish_zeroconf('Books in calibre', '_stanza._tcp',
self.opts.port, {'path':'/stanza'})
cherrypy.engine.block()
except Exception, e:
self.exception = e
finally:
self.is_running = False
stop_zeroconf()
def exit(self):
cherrypy.engine.exit()
def get_cover(self, id, thumbnail=False):
cover = self.db.cover(id, index_is_id=True, as_file=False)
if cover is None:
cover = server_resources['default_cover.jpg']
cherrypy.response.headers['Content-Type'] = 'image/jpeg'
path = getattr(cover, 'name', False)
updated = datetime.utcfromtimestamp(os.stat(path).st_mtime) if path and os.access(path, os.R_OK) else build_time
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
try:
if QApplication.instance() is None:
QApplication([])
im = QImage()
im.loadFromData(cover)
if im.isNull():
raise cherrypy.HTTPError(404, 'No valid cover found')
width, height = im.width(), im.height()
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.scaled(width, height, Qt.KeepAspectRatio, Qt.SmoothTransformation)
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QBuffer.WriteOnly)
im.save(buf, 'PNG')
return str(ba.data())
except Exception, err:
import traceback
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))
mt = guess_type('dummy.'+format.lower())[0]
if mt is None:
mt = 'application/octet-stream'
cherrypy.response.headers['Content-Type'] = mt
path = getattr(fmt, 'name', None)
if path and os.path.exists(path):
updated = datetime.utcfromtimestamp(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 '')
field = FIELD_MAP[field]
getter = operator.itemgetter(field)
items.sort(cmp=lambda x, y: cmpf(getter(x), getter(y)), reverse=not order)
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])
@expose
def stanza(self):
' Feeds to read calibre books on a ipod with stanza.'
books = []
for record in iter(self.db):
r = record[FIELD_MAP['formats']]
r = r.upper() if r else ''
if 'EPUB' in r or 'PDB' in r:
authors = ' & '.join([i.replace('|', ',') for i in
record[FIELD_MAP['authors']].split(',')])
extra = []
rating = record[FIELD_MAP['rating']]
if rating > 0:
rating = ''.join(repeat('&#9733;', rating))
extra.append('RATING: %s<br />'%rating)
tags = record[FIELD_MAP['tags']]
if tags:
extra.append('TAGS: %s<br />'%', '.join(tags.split(',')))
series = record[FIELD_MAP['series']]
if series:
extra.append('SERIES: %s [%d]<br />'%(series,
record[FIELD_MAP['series_index']]))
fmt = 'epub' if 'EPUB' in r else 'pdb'
mimetype = guess_type('dummy.'+fmt)[0]
books.append(self.STANZA_ENTRY.generate(
authors=authors,
record=record, FM=FIELD_MAP,
port=self.opts.port,
extra = ''.join(extra),
mimetype=mimetype,
fmt=fmt,
).render('xml').decode('utf8'))
updated = self.db.last_modified()
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
cherrypy.response.headers['Content-Type'] = 'text/xml'
return self.STANZA.generate(subtitle='', data=books, FM=FIELD_MAP,
updated=updated, id='urn:calibre:main').render('xml')
@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)
items = [r for r in iter(self.db) if r[0] 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[2] if record[2] else _('Unknown')
authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
books.append(book.generate(r=record, authors=authors).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)).render('xml')
@expose
def index(self, **kwargs):
'The / URL'
stanza = cherrypy.request.headers.get('Stanza-Device-Name', 919)
if stanza == 919:
return self.static('index.html')
return self.stanza()
@expose
def get(self, what, id):
'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(build_time)
if self.opts.develop and not getattr(sys, 'frozen', False) and \
name in ('gui.js', 'gui.css', 'index.html'):
path = os.path.join(os.path.dirname(__file__), 'static', name)
lm = datetime.fromtimestamp(os.stat(path).st_mtime)
cherrypy.response.headers['Last-Modified'] = self.last_modified(lm)
return open(path, 'rb').read()
else:
if server_resources.has_key(name):
return server_resources[name]
raise cherrypy.HTTPError(404, '%s not found'%name)
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():
return config().option_parser('%prog '+ _('[options]\n\nStart the calibre content server.'))
def main(args=sys.argv):
parser = option_parser()
opts, args = parser.parse_args(args)
cherrypy.log.screen = True
from calibre.utils.config import prefs
db = LibraryDatabase2(prefs['library_path'])
server = LibraryServer(db, opts)
server.start()
return 0
if __name__ == '__main__':
sys.exit(main())