7
7
HTTP server for remote access to the calibre database.
10
import sys, textwrap, mimetypes, operator, os, re, logging
10
import sys, textwrap, operator, os, re, logging
11
11
from itertools import repeat
12
12
from logging.handlers import RotatingFileHandler
13
13
from datetime import datetime
19
19
from calibre.constants import __version__, __appname__
20
20
from calibre.utils.genshi.template import MarkupTemplate
21
from calibre import fit_image
21
from calibre import fit_image, guess_type
22
22
from calibre.resources import jquery, server_resources, build_time
23
23
from calibre.library import server_config as config
24
24
from calibre.library.database2 import LibraryDatabase2, FIELD_MAP
25
25
from calibre.utils.config import config_dir
26
from calibre.utils.mdns import publish as publish_zeroconf, \
27
stop_server as stop_zeroconf
27
29
build_time = datetime.strptime(build_time, '%d %m %Y %H%M%S')
28
30
server_resources['jquery.js'] = jquery
32
34
def do(self, *args, **kwargs):
33
35
dict.update(cherrypy.response.headers, {'Server':self.server_name})
34
36
return func(self, *args, **kwargs)
36
38
return cherrypy.expose(do)
38
40
log_access_file = os.path.join(config_dir, 'server_access_log.txt')
39
41
log_error_file = os.path.join(config_dir, 'server_error_log.txt')
42
44
class LibraryServer(object):
44
46
server_name = __appname__ + '/' + __version__
46
48
BOOK = textwrap.dedent('''\
47
<book xmlns:py="http://genshi.edgewall.org/"
49
<book xmlns:py="http://genshi.edgewall.org/"
51
53
author_sort="${r[12]}"
54
timestamp="${r[5].strftime('%Y/%m/%d %H:%M:%S')}"
56
timestamp="${r[5].strftime('%Y/%m/%d %H:%M:%S')}"
56
58
isbn="${r[14] if r[14] else ''}"
57
59
formats="${r[13] if r[13] else ''}"
58
60
series = "${r[9] if r[9] else ''}"
74
76
STANZA_ENTRY=MarkupTemplate(textwrap.dedent('''\
75
77
<entry xmlns:py="http://genshi.edgewall.org/">
76
78
<title>${record[FM['title']]}</title>
77
79
<id>urn:calibre:${record[FM['id']]}</id>
78
80
<author><name>${authors}</name></author>
79
81
<updated>${record[FM['timestamp']].strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
80
<link type="application/epub+zip" href="/get/epub/${record[FM['id']]}" />
82
<link type="${mimetype}" href="/get/${fmt}/${record[FM['id']]}" />
81
83
<link rel="x-stanza-cover-image" type="image/jpeg" href="/get/cover/${record[FM['id']]}" />
82
84
<link rel="x-stanza-cover-image-thumbnail" type="image/jpeg" href="/get/thumb/${record[FM['id']]}" />
83
85
<content type="xhtml">
115
117
self.max_cover_width, self.max_cover_height = \
116
118
map(int, self.opts.max_cover.split('x'))
118
120
cherrypy.config.update({
119
121
'log.screen' : opts.develop,
120
122
'engine.autoreload_on' : opts.develop,
139
141
'tools.digest_auth.realm' : (_('Password to access your calibre library. Username is ') + opts.username.strip()).encode('ascii', 'replace'),
140
142
'tools.digest_auth.users' : {opts.username.strip():opts.password.strip()},
143
145
self.is_running = False
144
146
self.exception = None
146
148
def setup_loggers(self):
147
149
access_file = log_access_file
148
150
error_file = log_error_file
151
153
maxBytes = getattr(log, "rot_maxBytes", 10000000)
152
154
backupCount = getattr(log, "rot_backupCount", 1000)
154
156
# Make a new RotatingFileHandler for the error log.
155
157
h = RotatingFileHandler(error_file, 'a', maxBytes, backupCount)
156
158
h.setLevel(logging.DEBUG)
157
159
h.setFormatter(cherrypy._cplogging.logfmt)
158
160
log.error_log.addHandler(h)
160
162
# Make a new RotatingFileHandler for the access log.
161
163
h = RotatingFileHandler(access_file, 'a', maxBytes, backupCount)
162
164
h.setLevel(logging.DEBUG)
163
165
h.setFormatter(cherrypy._cplogging.logfmt)
164
166
log.access_log.addHandler(h)
168
170
self.is_running = False
169
171
self.setup_loggers()
172
174
cherrypy.engine.start()
173
175
self.is_running = True
176
publish_zeroconf('Books in calibre', '_stanza._tcp',
177
self.opts.port, {'path':'/stanza'})
174
178
cherrypy.engine.block()
175
179
except Exception, e:
176
180
self.exception = e
178
182
self.is_running = False
181
186
cherrypy.engine.exit()
183
188
def get_cover(self, id, thumbnail=False):
184
189
cover = self.db.cover(id, index_is_id=True, as_file=False)
185
190
if cover is None:
192
197
if QApplication.instance() is None:
196
201
im.loadFromData(cover)
198
203
raise cherrypy.HTTPError(404, 'No valid cover found')
199
204
width, height = im.width(), im.height()
200
scaled, width, height = fit_image(width, height,
201
60 if thumbnail else self.max_cover_width,
205
scaled, width, height = fit_image(width, height,
206
60 if thumbnail else self.max_cover_width,
202
207
80 if thumbnail else self.max_cover_height)
213
218
traceback.print_exc()
214
219
raise cherrypy.HTTPError(404, 'Failed to generate cover: %s'%err)
216
221
def get_format(self, id, format):
217
222
format = format.upper()
218
223
fmt = self.db.format(id, format, index_is_id=True, as_file=True, mode='rb')
220
225
raise cherrypy.HTTPError(404, 'book: %d does not have format: %s'%(id, format))
221
mt = mimetypes.guess_type('dummy.'+format.lower())[0]
226
mt = guess_type('dummy.'+format.lower())[0]
223
228
mt = 'application/octet-stream'
224
229
cherrypy.response.headers['Content-Type'] = mt
227
232
updated = datetime.utcfromtimestamp(os.stat(path).st_mtime)
228
233
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
229
234
return fmt.read()
231
236
def sort(self, items, field, order):
232
237
field = field.lower().strip()
233
238
if field == 'author':
238
243
raise cherrypy.HTTPError(400, '%s is not a valid sort field'%field)
239
244
cmpf = cmp if field in ('rating', 'size', 'timestamp') else \
240
245
lambda x, y: cmp(x.lower() if x else '', y.lower() if y else '')
241
field = FIELD_MAP[field]
242
getter = operator.itemgetter(field)
243
items.sort(cmp=lambda x, y: cmpf(getter(x), getter(y)), reverse=not order)
246
if field == 'series':
247
items.sort(cmp=self.seriescmp, reverse=not order)
249
field = FIELD_MAP[field]
250
getter = operator.itemgetter(field)
251
items.sort(cmp=lambda x, y: cmpf(getter(x), getter(y)), reverse=not order)
253
def seriescmp(self, x, y):
254
si = FIELD_MAP['series']
256
ans = cmp(x[si].lower(), y[si].lower())
257
except AttributeError: # Some entries may be None
258
ans = cmp(x[si], y[si])
259
if ans != 0: return ans
260
return cmp(x[FIELD_MAP['series_index']], y[FIELD_MAP['series_index']])
245
263
def last_modified(self, updated):
246
264
lm = updated.strftime('day, %d month %Y %H:%M:%S GMT')
247
265
day ={0:'Sun', 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat'}
249
267
month = {1:'Jan', 2:'Feb', 3:'Mar', 4:'Apr', 5:'May', 6:'Jun', 7:'Jul',
250
268
8:'Aug', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'}
251
269
return lm.replace('month', month[updated.month])
255
273
def stanza(self):
256
274
' Feeds to read calibre books on a ipod with stanza.'
258
276
for record in iter(self.db):
259
277
r = record[FIELD_MAP['formats']]
260
278
r = r.upper() if r else ''
262
authors = ' & '.join([i.replace('|', ',') for i in record[FIELD_MAP['authors']].split(',')])
279
if 'EPUB' in r or 'PDB' in r:
280
authors = ' & '.join([i.replace('|', ',') for i in
281
record[FIELD_MAP['authors']].split(',')])
264
283
rating = record[FIELD_MAP['rating']]
270
289
extra.append('TAGS: %s<br />'%', '.join(tags.split(',')))
271
290
series = record[FIELD_MAP['series']]
273
extra.append('SERIES: %s [%d]<br />'%(series, record[FIELD_MAP['series_index']]))
274
books.append(self.STANZA_ENTRY.generate(authors=authors,
275
record=record, FM=FIELD_MAP,
277
extra = ''.join(extra),
278
).render('xml').decode('utf8'))
292
extra.append('SERIES: %s [%d]<br />'%(series,
293
record[FIELD_MAP['series_index']]))
294
fmt = 'epub' if 'EPUB' in r else 'pdb'
295
mimetype = guess_type('dummy.'+fmt)[0]
296
books.append(self.STANZA_ENTRY.generate(
298
record=record, FM=FIELD_MAP,
300
extra = ''.join(extra),
303
).render('xml').decode('utf8'))
280
305
updated = self.db.last_modified()
281
306
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
282
307
cherrypy.response.headers['Content-Type'] = 'text/xml'
284
309
return self.STANZA.generate(subtitle='', data=books, FM=FIELD_MAP,
285
310
updated=updated, id='urn:calibre:main').render('xml')
288
def library(self, start='0', num='50', sort=None, search=None,
313
def library(self, start='0', num='50', sort=None, search=None,
289
314
_=None, order='ascending'):
291
316
Serves metadata from the calibre database as XML.
293
318
:param sort: Sort results by ``sort``. Can be one of `title,author,rating`.
294
319
:param search: Filter results by ``search`` query. See :class:`SearchQueryParser` for query syntax
295
320
:param start,num: Return the slice `[start:start+num]` of the sorted and filtered results
296
:param _: Firefox seems to sometimes send this when using XMLHttpRequest with no caching
321
:param _: Firefox seems to sometimes send this when using XMLHttpRequest with no caching
299
324
start = int(start)
309
334
items = [r for r in iter(self.db) if r[0] in ids]
310
335
if sort is not None:
311
336
self.sort(items, sort, order)
313
338
book, books = MarkupTemplate(self.BOOK), []
314
339
for record in items[start:start+num]:
315
aus = record[2] if record[2] else _('Unknown')
340
aus = record[2] if record[2] else __builtins__._('Unknown')
316
341
authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
317
342
books.append(book.generate(r=record, authors=authors).render('xml').decode('utf-8'))
318
343
updated = self.db.last_modified()
320
345
cherrypy.response.headers['Content-Type'] = 'text/xml'
321
346
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
322
return self.LIBRARY.generate(books=books, start=start, updated=updated,
347
return self.LIBRARY.generate(books=books, start=start, updated=updated,
323
348
total=len(ids)).render('xml')
326
351
def index(self, **kwargs):
328
return self.static('index.html')
353
stanza = cherrypy.request.headers.get('Stanza-Device-Name', 919)
355
return self.static('index.html')
331
360
def get(self, what, id):
332
361
'Serves files, covers, thumbnails from the calibre database'