mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
IGN:Initial implementation of the calibre content server
This commit is contained in:
parent
3af8332413
commit
7bc0b414cb
@ -2,7 +2,7 @@
|
|||||||
__license__ = 'GPL v3'
|
__license__ = 'GPL v3'
|
||||||
__copyright__ = '2008, Kovid Goyal <kovid@kovidgoyal.net>'
|
__copyright__ = '2008, Kovid Goyal <kovid@kovidgoyal.net>'
|
||||||
__docformat__ = 'restructuredtext en'
|
__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 htmlentitydefs import name2codepoint
|
||||||
from math import floor
|
from math import floor
|
||||||
from logging import Formatter
|
from logging import Formatter
|
||||||
@ -16,6 +16,10 @@ from calibre.constants import iswindows, isosx, islinux, isfrozen, \
|
|||||||
win32event, win32api, winerror, fcntl
|
win32event, win32api, winerror, fcntl
|
||||||
import mechanize
|
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):
|
def unicode_path(path, abs=False):
|
||||||
if not isinstance(path, unicode):
|
if not isinstance(path, unicode):
|
||||||
path = path.decode(sys.getfilesystemencoding())
|
path = path.decode(sys.getfilesystemencoding())
|
||||||
|
@ -589,13 +589,22 @@ class LibraryDatabase2(LibraryDatabase):
|
|||||||
ans.append(format)
|
ans.append(format)
|
||||||
return ','.join(ans)
|
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):
|
def format_abspath(self, index, format, index_is_id=False):
|
||||||
'Return absolute path to the ebook file of format `format`'
|
'Return absolute path to the ebook file of format `format`'
|
||||||
id = index if index_is_id else self.id(index)
|
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)
|
name = self.conn.get('SELECT name FROM data WHERE book=? AND format=?', (id, format), all=False)
|
||||||
if name:
|
if name:
|
||||||
|
path = os.path.join(self.library_path, self.path(id, index_is_id=True))
|
||||||
format = ('.' + format.lower()) if format else ''
|
format = ('.' + format.lower()) if format else ''
|
||||||
path = os.path.join(path, name+format)
|
path = os.path.join(path, name+format)
|
||||||
if os.access(path, os.R_OK|os.W_OK):
|
if os.access(path, os.R_OK|os.W_OK):
|
||||||
@ -612,7 +621,8 @@ class LibraryDatabase2(LibraryDatabase):
|
|||||||
if path is not None:
|
if path is not None:
|
||||||
f = open(path, mode)
|
f = open(path, mode)
|
||||||
return f if as_file else f.read()
|
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):
|
def add_format(self, index, format, stream, index_is_id=False, path=None):
|
||||||
id = index if index_is_id else self.id(index)
|
id = index if index_is_id else self.id(index)
|
||||||
@ -982,6 +992,14 @@ class LibraryDatabase2(LibraryDatabase):
|
|||||||
progress.hide()
|
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):
|
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
|
Return all metadata stored in the database as a dict. Includes paths to
|
||||||
@ -994,11 +1012,7 @@ class LibraryDatabase2(LibraryDatabase):
|
|||||||
prefix = self.library_path
|
prefix = self.library_path
|
||||||
FIELDS = set(['title', 'authors', 'publisher', 'rating', 'timestamp', 'size', 'tags', 'comments', 'series', 'series_index', 'isbn'])
|
FIELDS = set(['title', 'authors', 'publisher', 'rating', 'timestamp', 'size', 'tags', 'comments', 'series', 'series_index', 'isbn'])
|
||||||
data = []
|
data = []
|
||||||
if len(self.data) == 0:
|
for record in iter(self):
|
||||||
self.refresh('timestamp', True)
|
|
||||||
for record in self.data:
|
|
||||||
if record is None:
|
|
||||||
continue
|
|
||||||
x = {}
|
x = {}
|
||||||
for field in FIELDS:
|
for field in FIELDS:
|
||||||
x[field] = record[field]
|
x[field] = record[field]
|
||||||
|
@ -7,96 +7,182 @@ __docformat__ = 'restructuredtext en'
|
|||||||
HTTP server for remote access to the calibre database.
|
HTTP server for remote access to the calibre database.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
import sys, logging, re, SocketServer, gzip, cStringIO
|
import sys, textwrap, cStringIO, mimetypes
|
||||||
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
|
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.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 expose(func):
|
||||||
def __init__(self, opts, db):
|
|
||||||
|
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('''\
|
||||||
|
<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="${timestamp.ctime()}"
|
||||||
|
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/" size="${len(books)}">
|
||||||
|
<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['title']}</title>
|
||||||
|
<id>urn:calibre:${record['id']}</id>
|
||||||
|
<author><name>${authors}</name></author>
|
||||||
|
<updated>${record['timestamp'].strftime('%Y-%m-%dT%H:%M:%S+0000')}</updated>
|
||||||
|
<link type="application/epub+zip" href="http://${server}:${port}/get/epub/${record['id']}" />
|
||||||
|
<link rel="x-stanza-cover-image" type="image/jpeg" href="http://${server}:${port}/get/cover/${record['id']}" />
|
||||||
|
<link rel="x-stanza-cover-image-thumbnail" type="image/jpeg" href="http://${server}:${port}/get/thumb/${record['id']}" />
|
||||||
|
<content py:if="record['comments']" type="xhtml">
|
||||||
|
<pre>${record['comments']}</pre>
|
||||||
|
</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>
|
||||||
|
<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):
|
||||||
self.db = db
|
self.db = db
|
||||||
HTTPServer.__init__(self, ('', opts.port), DBHandler)
|
for item in self.db:
|
||||||
|
item
|
||||||
def serve_forever(self):
|
break
|
||||||
logging.getLogger('calibre.server').info('calibre-server starting...')
|
self.opts = opts
|
||||||
HTTPServer.serve_forever(self)
|
cherrypy.config.update({
|
||||||
|
'server.socket_port': opts.port,
|
||||||
|
'server.socket_timeout': opts.timeout, #seconds
|
||||||
class DBHandler(BaseHTTPRequestHandler):
|
'server.thread_pool': opts.thread_pool, # number of threads
|
||||||
|
})
|
||||||
server_version = 'calibre/'+__version__
|
self.config = textwrap.dedent('''\
|
||||||
protocol_version = 'HTTP/1.0'
|
[global]
|
||||||
cover_request = re.compile(r'/(\d+)/cover', re.IGNORECASE)
|
engine.autoreload_on = %(autoreload)s
|
||||||
thumbnail_request = re.compile(r'/(\d+)/thumb', re.IGNORECASE)
|
tools.gzip.on = True
|
||||||
fmt_request = re.compile(r'/(\d+)/([a-z0-9]+)', re.IGNORECASE)
|
tools.gzip.mime_types = ['text/html', 'text/plain', 'text/xml']
|
||||||
|
''')%dict(autoreload=opts.develop)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
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):
|
def start(self):
|
||||||
self.l.info("%s - - [%s] %s\n" %
|
cherrypy.quickstart(self, config=cStringIO.StringIO(self.config))
|
||||||
(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 send_help(self):
|
def get_cover(self, id, thumbnail=False):
|
||||||
self.send_error(501, 'Not Implemented')
|
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):
|
@expose
|
||||||
self.send_error(501, 'Not Implemented')
|
def library(self):
|
||||||
|
cherrypy.response.headers['Content-Type'] = 'text/xml'
|
||||||
def send_stanza_index(self):
|
return self.to_xml()
|
||||||
self.send_error(501, 'Not Implemented')
|
|
||||||
|
|
||||||
def send_library(self):
|
|
||||||
self.send_error(501, 'Not Implemented')
|
|
||||||
|
|
||||||
def send_cover(self, id, thumbnail=False):
|
@expose
|
||||||
self.send_error(501, 'Not Implemented')
|
def index(self):
|
||||||
|
return 'Hello, World!'
|
||||||
def send_format(self, id, fmt):
|
|
||||||
self.send_error(501, 'Not Implemented')
|
@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):
|
def config(defaults=None):
|
||||||
desc=_('Settings to control the calibre content server')
|
desc=_('Settings to control the calibre content server')
|
||||||
@ -104,8 +190,15 @@ def config(defaults=None):
|
|||||||
|
|
||||||
c.add_opt('port', ['-p', '--port'], default=8080,
|
c.add_opt('port', ['-p', '--port'], default=8080,
|
||||||
help=_('The port on which to listen. Default is %default'))
|
help=_('The port on which to listen. Default is %default'))
|
||||||
c.add_opt('debug', ['--debug'], default=False,
|
c.add_opt('timeout', ['-t', '--timeout'], default=120,
|
||||||
help=_('Detailed logging'))
|
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
|
return c
|
||||||
|
|
||||||
def option_parser():
|
def option_parser():
|
||||||
@ -114,23 +207,12 @@ def option_parser():
|
|||||||
def main(args=sys.argv):
|
def main(args=sys.argv):
|
||||||
parser = option_parser()
|
parser = option_parser()
|
||||||
opts, args = parser.parse_args(args)
|
opts, args = parser.parse_args(args)
|
||||||
l = logging.getLogger('calibre.server')
|
cherrypy.log.screen = True
|
||||||
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)
|
|
||||||
|
|
||||||
from calibre.utils.config import prefs
|
from calibre.utils.config import prefs
|
||||||
from calibre.library.database2 import LibraryDatabase2
|
from calibre.library.database2 import LibraryDatabase2
|
||||||
db = LibraryDatabase2(prefs['library_path'])
|
db = LibraryDatabase2(prefs['library_path'], row_factory=True)
|
||||||
try:
|
server = LibraryServer(db, opts)
|
||||||
print 'Starting server...'
|
server.start()
|
||||||
s = Server(opts, db)
|
|
||||||
s.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print 'Server interrupted'
|
|
||||||
s.socket.close()
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
Loading…
x
Reference in New Issue
Block a user