3
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
4
__docformat__ = 'restructuredtext en'
7
HTTP server for remote access to the calibre database.
10
import sys, textwrap, operator, os, re, logging, cStringIO
12
from itertools import repeat
13
from logging.handlers import RotatingFileHandler
14
from threading import Thread
18
from PIL import Image as PILImage
21
import Image as PILImage
23
from calibre.constants import __version__, __appname__, iswindows
24
from calibre.utils.genshi.template import MarkupTemplate
25
from calibre import fit_image, guess_type, prepare_string_for_xml, \
27
from calibre.library import server_config as config
28
from calibre.library.database2 import LibraryDatabase2
29
from calibre.utils.config import config_dir
30
from calibre.utils.mdns import publish as publish_zeroconf, \
31
stop_server as stop_zeroconf, get_external_ip
32
from calibre.ebooks.metadata import fmt_sidx, title_sort
33
from calibre.utils.date import now as nowf, fromtimestamp
37
def strftime(fmt='%Y/%m/%d %H:%M:%S', dt=None):
38
if not hasattr(dt, 'timetuple'):
42
return _strftime(fmt, dt)
44
return _strftime(fmt, nowf().timetuple())
48
def do(self, *args, **kwargs):
49
dict.update(cherrypy.response.headers, {'Server':self.server_name})
51
self.db.check_if_modified()
52
return func(self, *args, **kwargs)
54
return cherrypy.expose(do)
56
log_access_file = os.path.join(config_dir, 'server_access_log.txt')
57
log_error_file = os.path.join(config_dir, 'server_error_log.txt')
60
class LibraryServer(object):
62
server_name = __appname__ + '/' + __version__
64
BOOK = textwrap.dedent('''\
65
<book xmlns:py="http://genshi.edgewall.org/"
69
author_sort="${r[12]}"
72
timestamp="${timestamp}"
75
isbn="${r[14] if r[14] else ''}"
76
formats="${r[13] if r[13] else ''}"
77
series = "${r[9] if r[9] else ''}"
78
series_index="${r[10]}"
79
tags="${r[7] if r[7] else ''}"
80
publisher="${r[3] if r[3] else ''}">${r[8] if r[8] else ''}
84
MOBILE_UA = re.compile('(?i)(?:iPhone|Opera Mini|NetFront|webOS|Mobile|Android|imode|DoCoMo|Minimo|Blackberry|MIDP|Symbian|HD2)')
86
MOBILE_BOOK = textwrap.dedent('''\
87
<tr xmlns:py="http://genshi.edgewall.org/">
88
<td class="thumbnail">
89
<img type="image/jpeg" src="/get/thumb/${r[0]}" border="0"/>
92
<py:for each="format in r[13].split(',')">
93
<span class="button"><a href="/get/${format}/${authors}-${r[1]}_${r[0]}.${format}">${format.lower()}</a></span>
95
${r[1]}${(' ['+r[9]+'-'+r[10]+']') if r[9] else ''} by ${authors} - ${r[6]/1024}k - ${r[3] if r[3] else ''} ${pubdate} ${'['+r[7]+']' if r[7] else ''}
100
MOBILE = MarkupTemplate(textwrap.dedent('''\
101
<html xmlns:py="http://genshi.edgewall.org/">
104
.navigation table.buttons {
107
.navigation .button {
110
.button a, .button:visited a {
113
border: 1px solid black;
115
background-color: #ddd;
116
border-top: 1px solid ThreeDLightShadow;
117
border-right: 1px solid ButtonShadow;
118
border-bottom: 1px solid ButtonShadow;
119
border-left: 1 px solid ThreeDLightShadow;
120
-moz-border-radius: 0.25em;
121
-webkit-border-radius: 0.25em;
125
border-top: 1px solid #666;
126
border-right: 1px solid #CCC;
127
border-bottom: 1 px solid #CCC;
128
border-left: 1 px solid #666;
138
border: 1px solid #393;
139
-moz-border-radius: 0.5em;
140
-webkit-border-radius: 0.5em;
142
margin-bottom: 0.5em;
148
border-collapse: collapse;
154
#listing td.thumbnail {
159
#listing tr:nth-child(even) {
165
display: inline-block;
181
<link rel="icon" href="http://calibre-ebook.com/favicon.ico" type="image/x-icon" />
185
<img src="/static/calibre.png" alt="Calibre" />
187
<div id="search_box">
188
<form method="get" action="/mobile">
189
Show <select name="num">
190
<py:for each="option in [5,10,25,100]">
191
<option py:if="option == num" value="${option}" SELECTED="SELECTED">${option}</option>
192
<option py:if="option != num" value="${option}">${option}</option>
195
books matching <input name="search" id="s" value="${search}" /> sorted by
198
<py:for each="option in ['date','author','title','rating','size','tags','series']">
199
<option py:if="option == sort" value="${option}" SELECTED="SELECTED">${option}</option>
200
<option py:if="option != sort" value="${option}">${option}</option>
203
<select name="order">
204
<py:for each="option in ['ascending','descending']">
205
<option py:if="option == order" value="${option}" SELECTED="SELECTED">${option}</option>
206
<option py:if="option != order" value="${option}">${option}</option>
209
<input id="go" type="submit" value="Search"/>
212
<div class="navigation">
213
<span style="display: block; text-align: center;">Books ${start} to ${ min((start+num-1) , total) } of ${total}</span>
214
<table class="buttons">
216
<td class="button" style="text-align:left;">
217
<a py:if="start > 1" href="${url_base};start=1">First</a>
218
<a py:if="start > 1" href="${url_base};start=${max(start-(num+1),1)}">Previous</a>
220
<td class="button" style="text-align: right;">
221
<a py:if=" total > (start + num) " href="${url_base};start=${start+num}">Next</a>
222
<a py:if=" total > (start + num) " href="${url_base};start=${total-num+1}">Last</a>
227
<hr class="spacer" />
229
<py:for each="book in books">
237
LIBRARY = MarkupTemplate(textwrap.dedent('''\
238
<?xml version="1.0" encoding="utf-8"?>
239
<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')}">
240
<py:for each="book in books">
246
STANZA_ENTRY=MarkupTemplate(textwrap.dedent('''\
247
<entry xmlns:py="http://genshi.edgewall.org/">
248
<title>${record[FM['title']]}</title>
249
<id>urn:calibre:${urn}</id>
250
<author><name>${authors}</name></author>
251
<updated>${timestamp}</updated>
252
<link type="${mimetype}" href="/get/${fmt}/${record[FM['id']]}" />
253
<link rel="x-stanza-cover-image" type="image/jpeg" href="/get/cover/${record[FM['id']]}" />
254
<link rel="x-stanza-cover-image-thumbnail" type="image/jpeg" href="/get/thumb/${record[FM['id']]}" />
255
<content type="xhtml">
256
<div xmlns="http://www.w3.org/1999/xhtml" style="text-align: center">${Markup(extra)}${record[FM['comments']]}</div>
261
STANZA_SUBCATALOG_ENTRY=MarkupTemplate(textwrap.dedent('''\
262
<entry xmlns:py="http://genshi.edgewall.org/">
263
<title>${title}</title>
264
<id>urn:calibre:${id}</id>
265
<updated>${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
266
<link type="application/atom+xml" href="/stanza/?${what}id=${id}" />
267
<content type="text">${count} books</content>
271
STANZA = MarkupTemplate(textwrap.dedent('''\
272
<?xml version="1.0" encoding="utf-8"?>
273
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:py="http://genshi.edgewall.org/">
274
<title>calibre Library</title>
276
<updated>${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
277
<link rel="search" title="Search" type="application/atom+xml" href="/stanza/?search={searchTerms}"/>
281
<uri>http://calibre-ebook.com</uri>
286
<py:for each="entry in data">
292
STANZA_MAIN = MarkupTemplate(textwrap.dedent('''\
293
<?xml version="1.0" encoding="utf-8"?>
294
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:py="http://genshi.edgewall.org/">
295
<title>calibre Library</title>
297
<updated>${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
298
<link rel="search" title="Search" type="application/atom+xml" href="/stanza/?search={searchTerms}"/>
301
<uri>http://calibre-ebook.com</uri>
307
<title>By Author</title>
308
<id>urn:uuid:fc000fa0-8c23-11de-a31d-0002a5d5c51b</id>
309
<updated>${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
310
<link type="application/atom+xml" href="/stanza/?sortby=byauthor" />
311
<content type="text">Books sorted by Author</content>
314
<title>By Title</title>
315
<id>urn:uuid:1df4fe40-8c24-11de-b4c6-0002a5d5c51b</id>
316
<updated>${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
317
<link type="application/atom+xml" href="/stanza/?sortby=bytitle" />
318
<content type="text">Books sorted by Title</content>
321
<title>By Newest</title>
322
<id>urn:uuid:3c6d4940-8c24-11de-a4d7-0002a5d5c51b</id>
323
<updated>${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
324
<link type="application/atom+xml" href="/stanza/?sortby=bynewest" />
325
<content type="text">Books sorted by Date</content>
328
<title>By Tag</title>
329
<id>urn:uuid:824921e8-db8a-4e61-7d38-f1ce41502853</id>
330
<updated>${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
331
<link type="application/atom+xml" href="/stanza/?sortby=bytag" />
332
<content type="text">Books sorted by Tags</content>
335
<title>By Series</title>
336
<id>urn:uuid:512a5e50-a88f-f6b8-82aa-8f129c719f61</id>
337
<updated>${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}</updated>
338
<link type="application/atom+xml" href="/stanza/?sortby=byseries" />
339
<content type="text">Books sorted by Series</content>
345
def __init__(self, db, opts, embedded=False, show_tracebacks=True):
351
self.embedded = embedded
352
self.max_cover_width, self.max_cover_height = \
353
map(int, self.opts.max_cover.split('x'))
354
self.max_stanza_items = opts.max_opds_items
355
path = P('content_server')
356
self.build_time = fromtimestamp(os.stat(path).st_mtime)
357
self.default_cover = open(P('content_server/default_cover.jpg'), 'rb').read()
358
cherrypy.config.update({
359
'log.screen' : opts.develop,
360
'engine.autoreload_on' : opts.develop,
361
'tools.log_headers.on' : opts.develop,
362
'checker.on' : opts.develop,
363
'request.show_tracebacks': show_tracebacks,
364
'server.socket_host' : listen_on,
365
'server.socket_port' : opts.port,
366
'server.socket_timeout' : opts.timeout, #seconds
367
'server.thread_pool' : opts.thread_pool, # number of threads
370
cherrypy.config.update({'engine.SIGHUP' : None,
371
'engine.SIGTERM' : None,})
372
self.config = {'global': {
373
'tools.gzip.on' : True,
374
'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/xml', 'text/javascript', 'text/css'],
378
'tools.digest_auth.on' : True,
379
'tools.digest_auth.realm' : (_('Password to access your calibre library. Username is ') + opts.username.strip()).encode('ascii', 'replace'),
380
'tools.digest_auth.users' : {opts.username.strip():opts.password.strip()},
383
self.is_running = False
384
self.exception = None
386
def setup_loggers(self):
387
access_file = log_access_file
388
error_file = log_error_file
391
maxBytes = getattr(log, "rot_maxBytes", 10000000)
392
backupCount = getattr(log, "rot_backupCount", 1000)
394
# Make a new RotatingFileHandler for the error log.
395
h = RotatingFileHandler(error_file, 'a', maxBytes, backupCount)
396
h.setLevel(logging.DEBUG)
397
h.setFormatter(cherrypy._cplogging.logfmt)
398
log.error_log.addHandler(h)
400
# Make a new RotatingFileHandler for the access log.
401
h = RotatingFileHandler(access_file, 'a', maxBytes, backupCount)
402
h.setLevel(logging.DEBUG)
403
h.setFormatter(cherrypy._cplogging.logfmt)
404
log.access_log.addHandler(h)
407
self.is_running = False
409
cherrypy.tree.mount(self, '', config=self.config)
412
cherrypy.engine.start()
414
ip = get_external_ip()
415
if not ip or ip == '127.0.0.1':
417
cherrypy.log('Trying to bind to single interface: '+ip)
418
cherrypy.config.update({'server.socket_host' : ip})
419
cherrypy.engine.start()
421
self.is_running = True
423
publish_zeroconf('Books in calibre', '_stanza._tcp',
424
self.opts.port, {'path':'/stanza'})
427
cherrypy.log.error('Failed to start BonJour:')
428
cherrypy.log.error(traceback.format_exc())
429
cherrypy.engine.block()
433
self.is_running = False
438
cherrypy.log.error('Failed to stop BonJour:')
439
cherrypy.log.error(traceback.format_exc())
443
cherrypy.engine.exit()
445
cherrypy.server.httpserver = None
447
def get_cover(self, id, thumbnail=False):
448
cover = self.db.cover(id, index_is_id=True, as_file=False)
450
cover = self.default_cover
451
cherrypy.response.headers['Content-Type'] = 'image/jpeg'
452
cherrypy.response.timeout = 3600
453
path = getattr(cover, 'name', False)
454
updated = fromtimestamp(os.stat(path).st_mtime) if path and \
455
os.access(path, os.R_OK) else self.build_time
456
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
458
f = cStringIO.StringIO(cover)
460
im = PILImage.open(f)
462
raise cherrypy.HTTPError(404, 'No valid cover found')
463
width, height = im.size
464
scaled, width, height = fit_image(width, height,
465
60 if thumbnail else self.max_cover_width,
466
80 if thumbnail else self.max_cover_height)
469
im = im.resize((int(width), int(height)), PILImage.ANTIALIAS)
470
of = cStringIO.StringIO()
471
im.convert('RGB').save(of, 'JPEG')
473
except Exception, err:
475
cherrypy.log.error('Failed to generate cover:')
476
cherrypy.log.error(traceback.print_exc())
477
raise cherrypy.HTTPError(404, 'Failed to generate cover: %s'%err)
479
def get_format(self, id, format):
480
format = format.upper()
481
fmt = self.db.format(id, format, index_is_id=True, as_file=True,
484
raise cherrypy.HTTPError(404, 'book: %d does not have format: %s'%(id, format))
486
from tempfile import TemporaryFile
487
from calibre.ebooks.metadata.meta import set_metadata
489
fmt = TemporaryFile()
492
set_metadata(fmt, self.db.get_metadata(id, index_is_id=True),
495
mt = guess_type('dummy.'+format.lower())[0]
497
mt = 'application/octet-stream'
498
cherrypy.response.headers['Content-Type'] = mt
499
cherrypy.response.timeout = 3600
500
path = getattr(fmt, 'name', None)
501
if path and os.path.exists(path):
502
updated = fromtimestamp(os.stat(path).st_mtime)
503
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
506
def sort(self, items, field, order):
507
field = field.lower().strip()
508
if field == 'author':
512
if field not in ('title', 'authors', 'rating', 'timestamp', 'tags', 'size', 'series'):
513
raise cherrypy.HTTPError(400, '%s is not a valid sort field'%field)
514
cmpf = cmp if field in ('rating', 'size', 'timestamp') else \
515
lambda x, y: cmp(x.lower() if x else '', y.lower() if y else '')
516
if field == 'series':
517
items.sort(cmp=self.seriescmp, reverse=not order)
519
field = self.db.FIELD_MAP[field]
520
getter = operator.itemgetter(field)
521
items.sort(cmp=lambda x, y: cmpf(getter(x), getter(y)), reverse=not order)
523
def seriescmp(self, x, y):
524
si = self.db.FIELD_MAP['series']
526
ans = cmp(x[si].lower(), y[si].lower())
527
except AttributeError: # Some entries may be None
528
ans = cmp(x[si], y[si])
529
if ans != 0: return ans
530
return cmp(x[self.db.FIELD_MAP['series_index']], y[self.db.FIELD_MAP['series_index']])
533
def last_modified(self, updated):
534
lm = updated.strftime('day, %d month %Y %H:%M:%S GMT')
535
day ={0:'Sun', 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat'}
536
lm = lm.replace('day', day[int(updated.strftime('%w'))])
537
month = {1:'Jan', 2:'Feb', 3:'Mar', 4:'Apr', 5:'May', 6:'Jun', 7:'Jul',
538
8:'Aug', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'}
539
return lm.replace('month', month[updated.month])
541
def get_matches(self, location, query):
542
base = self.db.data.get_matches(location, query)
543
epub = self.db.data.get_matches('format', '=epub')
544
pdb = self.db.data.get_matches('format', '=pdb')
545
return base.intersection(epub.union(pdb))
547
def stanza_sortby_subcategory(self, updated, sortby, offset):
548
pat = re.compile(r'\(.*\)')
551
return pat.sub('', x).strip()
553
def author_cmp(x, y):
554
x = x if ',' in x else clean_author(x).rpartition(' ')[-1]
555
y = y if ',' in y else clean_author(y).rpartition(' ')[-1]
556
return cmp(x.lower(), y.lower())
559
pref, ___, suff = clean_author(x).rpartition(' ')
560
return suff + (', '+pref) if pref else suff
563
what, subtitle = sortby[2:], ''
564
if sortby == 'byseries':
565
data = self.db.all_series()
566
data = [(x[0], x[1], len(self.get_matches('series', '='+x[1]))) for x in data]
567
subtitle = 'Books by series'
568
elif sortby == 'byauthor':
569
data = self.db.all_authors()
570
data = [(x[0], x[1], len(self.get_matches('authors', '='+x[1]))) for x in data]
571
subtitle = 'Books by author'
572
elif sortby == 'bytag':
573
data = self.db.all_tags2()
574
data = [(x[0], x[1], len(self.get_matches('tags', '='+x[1]))) for x in data]
575
subtitle = 'Books by tag'
576
fcmp = author_cmp if sortby == 'byauthor' else cmp
577
data = [x for x in data if x[2] > 0]
578
data.sort(cmp=lambda x, y: fcmp(x[1], y[1]))
579
next_offset = offset + self.max_stanza_items
580
rdata = data[offset:next_offset]
581
if next_offset >= len(data):
583
gt = get_author if sortby == 'byauthor' else lambda x: x
584
entries = [self.STANZA_SUBCATALOG_ENTRY.generate(title=gt(title), id=id,
585
what=what, updated=updated, count=c).render('xml').decode('utf-8') for id,
589
next_link = ('<link rel="next" title="Next" '
590
'type="application/atom+xml" href="/stanza/?sortby=%s&offset=%d"/>\n'
591
) % (sortby, next_offset)
592
return self.STANZA.generate(subtitle=subtitle, data=entries, FM=self.db.FIELD_MAP,
593
updated=updated, id='urn:calibre:main', next_link=next_link).render('xml')
595
def stanza_main(self, updated):
596
return self.STANZA_MAIN.generate(subtitle='', data=[], FM=self.db.FIELD_MAP,
597
updated=updated, id='urn:calibre:main').render('xml')
600
def stanza(self, search=None, sortby=None, authorid=None, tagid=None,
601
seriesid=None, offset=0):
602
'Feeds to read calibre books on a ipod with stanza.'
604
updated = self.db.last_modified()
606
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
607
cherrypy.response.headers['Content-Type'] = 'text/xml'
609
if not sortby and not search and not authorid and not tagid and not seriesid:
610
return self.stanza_main(updated)
611
if sortby in ('byseries', 'byauthor', 'bytag'):
612
return self.stanza_sortby_subcategory(updated, sortby, offset)
616
authorid=int(authorid)
617
au = self.db.author_name(authorid)
618
ids = self.get_matches('authors', au)
621
ta = self.db.tag_name(tagid)
622
ids = self.get_matches('tags', ta)
624
seriesid=int(seriesid)
625
se = self.db.series_name(seriesid)
626
ids = self.get_matches('series', se)
628
ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
629
record_list = list(iter(self.db))
631
# Sort the record list
632
if sortby == "bytitle" or authorid or tagid:
633
record_list.sort(lambda x, y:
634
cmp(title_sort(x[self.db.FIELD_MAP['title']]),
635
title_sort(y[self.db.FIELD_MAP['title']])))
637
record_list.sort(lambda x, y:
638
cmp(x[self.db.FIELD_MAP['series_index']],
639
y[self.db.FIELD_MAP['series_index']]))
641
record_list = reversed(record_list)
644
fmts = self.db.FIELD_MAP['formats']
645
pat = re.compile(r'EPUB|PDB', re.IGNORECASE)
646
record_list = [x for x in record_list if x[0] in ids and
647
pat.search(x[fmts] if x[fmts] else '') is not None]
648
next_offset = offset + self.max_stanza_items
649
nrecord_list = record_list[offset:next_offset]
650
if next_offset >= len(record_list):
655
q = ['offset=%d'%next_offset]
656
for x in ('search', 'sortby', 'authorid', 'tagid', 'seriesid'):
659
val = prepare_string_for_xml(unicode(val), True)
660
q.append('%s=%s'%(x, val))
661
next_link = ('<link rel="next" title="Next" '
662
'type="application/atom+xml" href="/stanza/?%s"/>\n'
665
for record in nrecord_list:
666
r = record[self.db.FIELD_MAP['formats']]
667
r = r.upper() if r else ''
669
z = record[self.db.FIELD_MAP['authors']]
672
authors = ' & '.join([i.replace('|', ',') for i in
675
# Setup extra description
677
rating = record[self.db.FIELD_MAP['rating']]
679
rating = ''.join(repeat('★', rating))
680
extra.append('RATING: %s<br />'%rating)
681
tags = record[self.db.FIELD_MAP['tags']]
683
extra.append('TAGS: %s<br />'%\
684
prepare_string_for_xml(', '.join(tags.split(','))))
685
series = record[self.db.FIELD_MAP['series']]
687
extra.append('SERIES: %s [%s]<br />'%\
688
(prepare_string_for_xml(series),
689
fmt_sidx(float(record[self.db.FIELD_MAP['series_index']]))))
691
fmt = 'epub' if 'EPUB' in r else 'pdb'
692
mimetype = guess_type('dummy.'+fmt)[0]
694
# Create the sub-catalog, which is either a list of
695
# authors/tags/series or a list of books
702
FM=self.db.FIELD_MAP,
703
extra='\n'.join(extra),
706
urn=record[self.db.FIELD_MAP['uuid']],
707
timestamp=strftime('%Y-%m-%dT%H:%M:%S+00:00', record[5])
709
books.append(self.STANZA_ENTRY.generate(**data)\
710
.render('xml').decode('utf8'))
712
return self.STANZA.generate(subtitle='', data=books, FM=self.db.FIELD_MAP,
713
next_link=next_link, updated=updated, id='urn:calibre:main').render('xml')
717
def mobile(self, start='1', num='25', sort='date', search='',
718
_=None, order='descending'):
720
Serves metadata from the calibre database as XML.
722
:param sort: Sort results by ``sort``. Can be one of `title,author,rating`.
723
:param search: Filter results by ``search`` query. See :class:`SearchQueryParser` for query syntax
724
:param start,num: Return the slice `[start:start+num]` of the sorted and filtered results
725
:param _: Firefox seems to sometimes send this when using XMLHttpRequest with no caching
730
raise cherrypy.HTTPError(400, 'start: %s is not an integer'%start)
734
raise cherrypy.HTTPError(400, 'num: %s is not an integer'%num)
735
ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
737
items = [r for r in iter(self.db) if r[0] in ids]
739
self.sort(items, sort, (order.lower().strip() == 'ascending'))
741
book, books = MarkupTemplate(self.MOBILE_BOOK), []
742
for record in items[(start-1):(start-1)+num]:
743
if record[13] is None:
745
if record[6] is None:
747
aus = record[2] if record[2] else __builtin__._('Unknown')
748
authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
749
record[10] = fmt_sidx(float(record[10]))
750
ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[5]), \
751
strftime('%Y/%m/%d %H:%M:%S', record[self.db.FIELD_MAP['pubdate']])
752
books.append(book.generate(r=record, authors=authors, timestamp=ts,
753
pubdate=pd).render('xml').decode('utf-8'))
754
updated = self.db.last_modified()
756
cherrypy.response.headers['Content-Type'] = 'text/html; charset=utf-8'
757
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
760
url_base = "/mobile?search=" + search+";order="+order+";sort="+sort+";num="+str(num)
762
return self.MOBILE.generate(books=books, start=start, updated=updated, search=search, sort=sort, order=order, num=num,
763
total=len(ids), url_base=url_base).render('html')
767
def library(self, start='0', num='50', sort=None, search=None,
768
_=None, order='ascending'):
770
Serves metadata from the calibre database as XML.
772
:param sort: Sort results by ``sort``. Can be one of `title,author,rating`.
773
:param search: Filter results by ``search`` query. See :class:`SearchQueryParser` for query syntax
774
:param start,num: Return the slice `[start:start+num]` of the sorted and filtered results
775
:param _: Firefox seems to sometimes send this when using XMLHttpRequest with no caching
780
raise cherrypy.HTTPError(400, 'start: %s is not an integer'%start)
784
raise cherrypy.HTTPError(400, 'num: %s is not an integer'%num)
785
order = order.lower().strip() == 'ascending'
786
ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
788
items = [r for r in iter(self.db) if r[0] in ids]
790
self.sort(items, sort, order)
792
book, books = MarkupTemplate(self.BOOK), []
793
for record in items[start:start+num]:
794
aus = record[2] if record[2] else __builtin__._('Unknown')
795
authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
796
record[10] = fmt_sidx(float(record[10]))
797
ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[5]), \
798
strftime('%Y/%m/%d %H:%M:%S', record[self.db.FIELD_MAP['pubdate']])
799
books.append(book.generate(r=record, authors=authors, timestamp=ts,
800
pubdate=pd).render('xml').decode('utf-8'))
801
updated = self.db.last_modified()
803
cherrypy.response.headers['Content-Type'] = 'text/xml'
804
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
805
return self.LIBRARY.generate(books=books, start=start, updated=updated,
806
total=len(ids)).render('xml')
809
def index(self, **kwargs):
811
ua = cherrypy.request.headers.get('User-Agent', '').strip()
813
cherrypy.request.headers.get('Stanza-Device-Name', 919) != 919 or \
814
cherrypy.request.headers.get('Want-OPDS-Catalog', 919) != 919 or \
815
ua.startswith('Stanza')
817
# A better search would be great
818
want_mobile = self.MOBILE_UA.search(ua) is not None
819
if self.opts.develop and not want_mobile:
820
cherrypy.log('User agent: '+ua)
823
return self.stanza(search=kwargs.get('search', None), sortby=kwargs.get('sortby',None), authorid=kwargs.get('authorid',None),
824
tagid=kwargs.get('tagid',None),
825
seriesid=kwargs.get('seriesid',None),
826
offset=kwargs.get('offset', 0))
831
return self.static('index.html')
835
def get(self, what, id, *args, **kwargs):
836
'Serves files, covers, thumbnails from the calibre database'
840
id = id.rpartition('_')[-1].partition('.')[0]
841
match = re.search(r'\d+', id)
843
raise cherrypy.HTTPError(400, 'id:%s not an integer'%id)
844
id = int(match.group())
845
if not self.db.has_id(id):
846
raise cherrypy.HTTPError(400, 'id:%d does not exist in database'%id)
848
return self.get_cover(id, thumbnail=True)
850
return self.get_cover(id)
851
return self.get_format(id, what)
854
def static(self, name):
855
'Serves static content'
857
cherrypy.response.headers['Content-Type'] = {
858
'js' : 'text/javascript',
862
'html' : 'text/html',
863
'' : 'application/octet-stream',
864
}[name.rpartition('.')[-1].lower()]
865
cherrypy.response.headers['Last-Modified'] = self.last_modified(self.build_time)
866
path = P('content_server/'+name)
867
if not os.path.exists(path):
868
raise cherrypy.HTTPError(404, '%s not found'%name)
869
if self.opts.develop:
870
lm = fromtimestamp(os.stat(path).st_mtime)
871
cherrypy.response.headers['Last-Modified'] = self.last_modified(lm)
872
return open(path, 'rb').read()
874
def start_threaded_server(db, opts):
875
server = LibraryServer(db, opts, embedded=True)
876
server.thread = Thread(target=server.start)
877
server.thread.setDaemon(True)
878
server.thread.start()
881
def stop_threaded_server(server):
886
parser = config().option_parser('%prog '+ _('[options]\n\nStart the calibre content server.'))
887
parser.add_option('--with-library', default=None,
888
help=_('Path to the library folder to serve with the content server'))
889
parser.add_option('--pidfile', default=None,
890
help=_('Write process PID to the specified file'))
891
parser.add_option('--daemonize', default=False, action='store_true',
892
help='Run process in background as a daemon. No effect on windows.')
895
def daemonize(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'):
902
print >>sys.stderr, "fork #1 failed: %d (%s)" % (e.errno, e.strerror)
905
# decouple from parent environment
914
# exit from second parent
917
print >>sys.stderr, "fork #2 failed: %d (%s)" % (e.errno, e.strerror)
920
# Redirect standard file descriptors.
921
si = file(stdin, 'r')
922
so = file(stdout, 'a+')
923
se = file(stderr, 'a+', 0)
924
os.dup2(si.fileno(), sys.stdin.fileno())
925
os.dup2(so.fileno(), sys.stdout.fileno())
926
os.dup2(se.fileno(), sys.stderr.fileno())
930
def main(args=sys.argv):
931
parser = option_parser()
932
opts, args = parser.parse_args(args)
933
if opts.daemonize and not iswindows:
935
if opts.pidfile is not None:
936
with open(opts.pidfile, 'wb') as f:
937
f.write(str(os.getpid()))
938
cherrypy.log.screen = True
939
from calibre.utils.config import prefs
940
if opts.with_library is None:
941
opts.with_library = prefs['library_path']
942
db = LibraryDatabase2(opts.with_library)
943
server = LibraryServer(db, opts)
947
if __name__ == '__main__':