~ubuntu-branches/ubuntu/oneiric/calibre/oneiric

« back to all changes in this revision

Viewing changes to src/calibre/library/server.py

  • Committer: Bazaar Package Importer
  • Author(s): Martin Pitt
  • Date: 2010-06-21 10:18:08 UTC
  • mfrom: (1.3.12 upstream)
  • Revision ID: james.westby@ubuntu.com-20100621101808-aue828f532tmo4zt
Tags: 0.7.2+dfsg-1
* New major upstream version. See http://calibre-ebook.com/new-in/seven for
  details.
* Refresh patches to apply cleanly.
* debian/control: Bump python-cssutils to >= 0.9.7~ to ensure the existence
  of the CSSRuleList.rulesOfType attribute. This makes epub conversion work
  again. (Closes: #584756)
* Add debian/local/calibre-mount-helper: Simple and safe replacement for
  upstream's calibre-mount-helper, using udisks --mount and eject.
  (Closes: #584915, LP: #561958)

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#!/usr/bin/env  python
2
 
__license__   = 'GPL v3'
3
 
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
4
 
__docformat__ = 'restructuredtext en'
5
 
 
6
 
'''
7
 
HTTP server for remote access to the calibre database.
8
 
'''
9
 
 
10
 
import sys, textwrap, operator, os, re, logging, cStringIO
11
 
import __builtin__
12
 
from itertools import repeat
13
 
from logging.handlers import RotatingFileHandler
14
 
from threading import Thread
15
 
 
16
 
import cherrypy
17
 
try:
18
 
    from PIL import Image as PILImage
19
 
    PILImage
20
 
except ImportError:
21
 
    import Image as PILImage
22
 
 
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, \
26
 
        strftime as _strftime
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
34
 
 
35
 
listen_on = '0.0.0.0'
36
 
 
37
 
def strftime(fmt='%Y/%m/%d %H:%M:%S', dt=None):
38
 
    if not hasattr(dt, 'timetuple'):
39
 
        dt = nowf()
40
 
    dt = dt.timetuple()
41
 
    try:
42
 
        return _strftime(fmt, dt)
43
 
    except:
44
 
        return _strftime(fmt, nowf().timetuple())
45
 
 
46
 
def expose(func):
47
 
 
48
 
    def do(self, *args, **kwargs):
49
 
        dict.update(cherrypy.response.headers, {'Server':self.server_name})
50
 
        if not self.embedded:
51
 
            self.db.check_if_modified()
52
 
        return func(self, *args, **kwargs)
53
 
 
54
 
    return cherrypy.expose(do)
55
 
 
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')
58
 
 
59
 
 
60
 
class LibraryServer(object):
61
 
 
62
 
    server_name = __appname__ + '/' + __version__
63
 
 
64
 
    BOOK = textwrap.dedent('''\
65
 
        <book xmlns:py="http://genshi.edgewall.org/"
66
 
            id="${r[0]}"
67
 
            title="${r[1]}"
68
 
            sort="${r[11]}"
69
 
            author_sort="${r[12]}"
70
 
            authors="${authors}"
71
 
            rating="${r[4]}"
72
 
            timestamp="${timestamp}"
73
 
            pubdate="${pubdate}"
74
 
            size="${r[6]}"
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 ''}
81
 
            </book>
82
 
        ''')
83
 
 
84
 
    MOBILE_UA = re.compile('(?i)(?:iPhone|Opera Mini|NetFront|webOS|Mobile|Android|imode|DoCoMo|Minimo|Blackberry|MIDP|Symbian|HD2)')
85
 
 
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"/>
90
 
    </td>
91
 
    <td>
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>&nbsp;
94
 
        </py:for>
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 ''}
96
 
    </td>
97
 
    </tr>
98
 
    ''')
99
 
 
100
 
    MOBILE = MarkupTemplate(textwrap.dedent('''\
101
 
    <html xmlns:py="http://genshi.edgewall.org/">
102
 
    <head>
103
 
    <style>
104
 
    .navigation table.buttons {
105
 
        width: 100%;
106
 
    }
107
 
    .navigation .button {
108
 
        width: 50%;
109
 
    }
110
 
    .button a, .button:visited a {
111
 
        padding: 0.5em;
112
 
        font-size: 1.25em;
113
 
        border: 1px solid black;
114
 
        text-color: 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;
122
 
    }
123
 
 
124
 
    .button:hover a {
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;
129
 
 
130
 
 
131
 
    }
132
 
    div.navigation {
133
 
        padding-bottom: 1em;
134
 
        clear: both;
135
 
    }
136
 
 
137
 
    #search_box {
138
 
        border: 1px solid #393;
139
 
        -moz-border-radius: 0.5em;
140
 
        -webkit-border-radius: 0.5em;
141
 
        padding: 1em;
142
 
        margin-bottom: 0.5em;
143
 
        float: right;
144
 
    }
145
 
 
146
 
    #listing {
147
 
        width: 100%;
148
 
        border-collapse: collapse;
149
 
    }
150
 
    #listing td {
151
 
        padding: 0.25em;
152
 
    }
153
 
 
154
 
    #listing td.thumbnail {
155
 
        height: 60px;
156
 
        width: 60px;
157
 
    }
158
 
 
159
 
    #listing tr:nth-child(even) {
160
 
 
161
 
        background: #eee;
162
 
    }
163
 
 
164
 
    #listing .button a{
165
 
        display: inline-block;
166
 
        width: 2.5em;
167
 
        padding-left: 0em;
168
 
        padding-right: 0em;
169
 
        overflow: hidden;
170
 
        text-align: center;
171
 
    }
172
 
 
173
 
    #logo {
174
 
        float: left;
175
 
    }
176
 
    #spacer {
177
 
        clear: both;
178
 
    }
179
 
 
180
 
    </style>
181
 
    <link rel="icon" href="http://calibre-ebook.com/favicon.ico" type="image/x-icon" />
182
 
    </head>
183
 
    <body>
184
 
        <div id="logo">
185
 
        <img src="/static/calibre.png" alt="Calibre" />
186
 
        </div>
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>
193
 
            </py:for>
194
 
            </select>
195
 
        books matching <input name="search" id="s" value="${search}" /> sorted by
196
 
 
197
 
        <select name="sort">
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>
201
 
            </py:for>
202
 
        </select>
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>
207
 
            </py:for>
208
 
        </select>
209
 
        <input id="go" type="submit" value="Search"/>
210
 
        </form>
211
 
        </div>
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">
215
 
        <tr>
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>
219
 
                </td>
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>
223
 
        </td>
224
 
        </tr>
225
 
        </table>
226
 
        </div>
227
 
        <hr class="spacer" />
228
 
        <table id="listing">
229
 
            <py:for each="book in books">
230
 
                ${Markup(book)}
231
 
            </py:for>
232
 
        </table>
233
 
    </body>
234
 
    </html>
235
 
    '''))
236
 
 
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">
241
 
        ${Markup(book)}
242
 
    </py:for>
243
 
    </library>
244
 
    '''))
245
 
 
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>
257
 
        </content>
258
 
    </entry>
259
 
    '''))
260
 
 
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>
268
 
    </entry>
269
 
    '''))
270
 
 
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>
275
 
      <id>$id</id>
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}"/>
278
 
      ${Markup(next_link)}
279
 
      <author>
280
 
        <name>calibre</name>
281
 
        <uri>http://calibre-ebook.com</uri>
282
 
      </author>
283
 
      <subtitle>
284
 
            ${subtitle}
285
 
      </subtitle>
286
 
      <py:for each="entry in data">
287
 
      ${Markup(entry)}
288
 
      </py:for>
289
 
    </feed>
290
 
    '''))
291
 
 
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>
296
 
      <id>$id</id>
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}"/>
299
 
      <author>
300
 
        <name>calibre</name>
301
 
        <uri>http://calibre-ebook.com</uri>
302
 
      </author>
303
 
      <subtitle>
304
 
            ${subtitle}
305
 
      </subtitle>
306
 
      <entry>
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>
312
 
      </entry>
313
 
      <entry>
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>
319
 
      </entry>
320
 
      <entry>
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>
326
 
      </entry>
327
 
      <entry>
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>
333
 
      </entry>
334
 
      <entry>
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>
340
 
      </entry>
341
 
    </feed>
342
 
    '''))
343
 
 
344
 
 
345
 
    def __init__(self, db, opts, embedded=False, show_tracebacks=True):
346
 
        self.db = db
347
 
        for item in self.db:
348
 
            item
349
 
            break
350
 
        self.opts = opts
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
368
 
                               })
369
 
        if embedded:
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'],
375
 
        }}
376
 
        if opts.password:
377
 
            self.config['/'] = {
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()},
381
 
                      }
382
 
 
383
 
        self.is_running = False
384
 
        self.exception = None
385
 
 
386
 
    def setup_loggers(self):
387
 
        access_file = log_access_file
388
 
        error_file  = log_error_file
389
 
        log = cherrypy.log
390
 
 
391
 
        maxBytes = getattr(log, "rot_maxBytes", 10000000)
392
 
        backupCount = getattr(log, "rot_backupCount", 1000)
393
 
 
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)
399
 
 
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)
405
 
 
406
 
    def start(self):
407
 
        self.is_running = False
408
 
        self.setup_loggers()
409
 
        cherrypy.tree.mount(self, '', config=self.config)
410
 
        try:
411
 
            try:
412
 
                cherrypy.engine.start()
413
 
            except:
414
 
                ip = get_external_ip()
415
 
                if not ip or ip == '127.0.0.1':
416
 
                    raise
417
 
                cherrypy.log('Trying to bind to single interface: '+ip)
418
 
                cherrypy.config.update({'server.socket_host' : ip})
419
 
                cherrypy.engine.start()
420
 
 
421
 
            self.is_running = True
422
 
            try:
423
 
                publish_zeroconf('Books in calibre', '_stanza._tcp',
424
 
                             self.opts.port, {'path':'/stanza'})
425
 
            except:
426
 
                import traceback
427
 
                cherrypy.log.error('Failed to start BonJour:')
428
 
                cherrypy.log.error(traceback.format_exc())
429
 
            cherrypy.engine.block()
430
 
        except Exception, e:
431
 
            self.exception = e
432
 
        finally:
433
 
            self.is_running = False
434
 
            try:
435
 
                stop_zeroconf()
436
 
            except:
437
 
                import traceback
438
 
                cherrypy.log.error('Failed to stop BonJour:')
439
 
                cherrypy.log.error(traceback.format_exc())
440
 
 
441
 
    def exit(self):
442
 
        try:
443
 
            cherrypy.engine.exit()
444
 
        finally:
445
 
            cherrypy.server.httpserver = None
446
 
 
447
 
    def get_cover(self, id, thumbnail=False):
448
 
        cover = self.db.cover(id, index_is_id=True, as_file=False)
449
 
        if cover is None:
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)
457
 
        try:
458
 
            f = cStringIO.StringIO(cover)
459
 
            try:
460
 
                im = PILImage.open(f)
461
 
            except IOError:
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)
467
 
            if not scaled:
468
 
                return cover
469
 
            im = im.resize((int(width), int(height)), PILImage.ANTIALIAS)
470
 
            of = cStringIO.StringIO()
471
 
            im.convert('RGB').save(of, 'JPEG')
472
 
            return of.getvalue()
473
 
        except Exception, err:
474
 
            import traceback
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)
478
 
 
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,
482
 
                mode='rb')
483
 
        if fmt is None:
484
 
            raise cherrypy.HTTPError(404, 'book: %d does not have format: %s'%(id, format))
485
 
        if format == 'EPUB':
486
 
            from tempfile import TemporaryFile
487
 
            from calibre.ebooks.metadata.meta import set_metadata
488
 
            raw = fmt.read()
489
 
            fmt = TemporaryFile()
490
 
            fmt.write(raw)
491
 
            fmt.seek(0)
492
 
            set_metadata(fmt, self.db.get_metadata(id, index_is_id=True),
493
 
                    'epub')
494
 
            fmt.seek(0)
495
 
        mt = guess_type('dummy.'+format.lower())[0]
496
 
        if mt is None:
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)
504
 
        return fmt.read()
505
 
 
506
 
    def sort(self, items, field, order):
507
 
        field = field.lower().strip()
508
 
        if field == 'author':
509
 
            field = 'authors'
510
 
        if field == 'date':
511
 
            field = 'timestamp'
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)
518
 
        else:
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)
522
 
 
523
 
    def seriescmp(self, x, y):
524
 
        si = self.db.FIELD_MAP['series']
525
 
        try:
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']])
531
 
 
532
 
 
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])
540
 
 
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))
546
 
 
547
 
    def stanza_sortby_subcategory(self, updated, sortby, offset):
548
 
        pat = re.compile(r'\(.*\)')
549
 
 
550
 
        def clean_author(x):
551
 
            return pat.sub('', x).strip()
552
 
 
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())
557
 
 
558
 
        def get_author(x):
559
 
            pref, ___, suff = clean_author(x).rpartition(' ')
560
 
            return suff + (', '+pref) if pref else suff
561
 
 
562
 
 
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):
582
 
            next_offset = -1
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,
586
 
            title, c in rdata]
587
 
        next_link = ''
588
 
        if next_offset > -1:
589
 
            next_link = ('<link rel="next" title="Next" '
590
 
            'type="application/atom+xml" href="/stanza/?sortby=%s&amp;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')
594
 
 
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')
598
 
 
599
 
    @expose
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.'
603
 
        books = []
604
 
        updated = self.db.last_modified()
605
 
        offset = int(offset)
606
 
        cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
607
 
        cherrypy.response.headers['Content-Type'] = 'text/xml'
608
 
        # Main feed
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)
613
 
 
614
 
        # Get matching ids
615
 
        if authorid:
616
 
            authorid=int(authorid)
617
 
            au = self.db.author_name(authorid)
618
 
            ids = self.get_matches('authors', au)
619
 
        elif tagid:
620
 
            tagid=int(tagid)
621
 
            ta = self.db.tag_name(tagid)
622
 
            ids = self.get_matches('tags', ta)
623
 
        elif seriesid:
624
 
            seriesid=int(seriesid)
625
 
            se = self.db.series_name(seriesid)
626
 
            ids = self.get_matches('series', se)
627
 
        else:
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))
630
 
 
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']])))
636
 
        elif seriesid:
637
 
            record_list.sort(lambda x, y:
638
 
                    cmp(x[self.db.FIELD_MAP['series_index']],
639
 
                        y[self.db.FIELD_MAP['series_index']]))
640
 
        else: # Sort by date
641
 
            record_list = reversed(record_list)
642
 
 
643
 
 
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):
651
 
            next_offset = -1
652
 
 
653
 
        next_link = ''
654
 
        if next_offset > -1:
655
 
            q = ['offset=%d'%next_offset]
656
 
            for x in ('search', 'sortby', 'authorid', 'tagid', 'seriesid'):
657
 
                val = locals()[x]
658
 
                if val is not None:
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'
663
 
            ) % '&amp;'.join(q)
664
 
 
665
 
        for record in nrecord_list:
666
 
            r = record[self.db.FIELD_MAP['formats']]
667
 
            r = r.upper() if r else ''
668
 
 
669
 
            z = record[self.db.FIELD_MAP['authors']]
670
 
            if not z:
671
 
                z = _('Unknown')
672
 
            authors = ' & '.join([i.replace('|', ',') for i in
673
 
                                    z.split(',')])
674
 
 
675
 
            # Setup extra description
676
 
            extra = []
677
 
            rating = record[self.db.FIELD_MAP['rating']]
678
 
            if rating > 0:
679
 
                rating = ''.join(repeat('&#9733;', rating))
680
 
                extra.append('RATING: %s<br />'%rating)
681
 
            tags = record[self.db.FIELD_MAP['tags']]
682
 
            if tags:
683
 
                extra.append('TAGS: %s<br />'%\
684
 
                        prepare_string_for_xml(', '.join(tags.split(','))))
685
 
            series = record[self.db.FIELD_MAP['series']]
686
 
            if 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']]))))
690
 
 
691
 
            fmt = 'epub' if 'EPUB' in r else 'pdb'
692
 
            mimetype = guess_type('dummy.'+fmt)[0]
693
 
 
694
 
            # Create the sub-catalog, which is either a list of
695
 
            # authors/tags/series or a list of books
696
 
            data = dict(
697
 
                    record=record,
698
 
                    updated=updated,
699
 
                    authors=authors,
700
 
                    tags=tags,
701
 
                    series=series,
702
 
                    FM=self.db.FIELD_MAP,
703
 
                    extra='\n'.join(extra),
704
 
                    mimetype=mimetype,
705
 
                    fmt=fmt,
706
 
                    urn=record[self.db.FIELD_MAP['uuid']],
707
 
                    timestamp=strftime('%Y-%m-%dT%H:%M:%S+00:00', record[5])
708
 
                    )
709
 
            books.append(self.STANZA_ENTRY.generate(**data)\
710
 
                                        .render('xml').decode('utf8'))
711
 
 
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')
714
 
 
715
 
 
716
 
    @expose
717
 
    def mobile(self, start='1', num='25', sort='date', search='',
718
 
                _=None, order='descending'):
719
 
        '''
720
 
        Serves metadata from the calibre database as XML.
721
 
 
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
726
 
        '''
727
 
        try:
728
 
            start = int(start)
729
 
        except ValueError:
730
 
            raise cherrypy.HTTPError(400, 'start: %s is not an integer'%start)
731
 
        try:
732
 
            num = int(num)
733
 
        except ValueError:
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()
736
 
        ids = sorted(ids)
737
 
        items = [r for r in iter(self.db) if r[0] in ids]
738
 
        if sort is not None:
739
 
            self.sort(items, sort, (order.lower().strip() == 'ascending'))
740
 
 
741
 
        book, books = MarkupTemplate(self.MOBILE_BOOK), []
742
 
        for record in items[(start-1):(start-1)+num]:
743
 
            if record[13] is None:
744
 
                record[13] = ''
745
 
            if record[6] is None:
746
 
                record[6] = 0
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()
755
 
 
756
 
        cherrypy.response.headers['Content-Type'] = 'text/html; charset=utf-8'
757
 
        cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
758
 
 
759
 
 
760
 
        url_base = "/mobile?search=" + search+";order="+order+";sort="+sort+";num="+str(num)
761
 
 
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')
764
 
 
765
 
 
766
 
    @expose
767
 
    def library(self, start='0', num='50', sort=None, search=None,
768
 
                _=None, order='ascending'):
769
 
        '''
770
 
        Serves metadata from the calibre database as XML.
771
 
 
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
776
 
        '''
777
 
        try:
778
 
            start = int(start)
779
 
        except ValueError:
780
 
            raise cherrypy.HTTPError(400, 'start: %s is not an integer'%start)
781
 
        try:
782
 
            num = int(num)
783
 
        except ValueError:
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()
787
 
        ids = sorted(ids)
788
 
        items = [r for r in iter(self.db) if r[0] in ids]
789
 
        if sort is not None:
790
 
            self.sort(items, sort, order)
791
 
 
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()
802
 
 
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')
807
 
 
808
 
    @expose
809
 
    def index(self, **kwargs):
810
 
        'The / URL'
811
 
        ua = cherrypy.request.headers.get('User-Agent', '').strip()
812
 
        want_opds = \
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')
816
 
 
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)
821
 
 
822
 
        if want_opds:
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))
827
 
 
828
 
        if want_mobile:
829
 
            return self.mobile()
830
 
 
831
 
        return self.static('index.html')
832
 
 
833
 
 
834
 
    @expose
835
 
    def get(self, what, id, *args, **kwargs):
836
 
        'Serves files, covers, thumbnails from the calibre database'
837
 
        try:
838
 
            id = int(id)
839
 
        except ValueError:
840
 
            id = id.rpartition('_')[-1].partition('.')[0]
841
 
            match = re.search(r'\d+', id)
842
 
            if not match:
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)
847
 
        if what == 'thumb':
848
 
            return self.get_cover(id, thumbnail=True)
849
 
        if what == 'cover':
850
 
            return self.get_cover(id)
851
 
        return self.get_format(id, what)
852
 
 
853
 
    @expose
854
 
    def static(self, name):
855
 
        'Serves static content'
856
 
        name = name.lower()
857
 
        cherrypy.response.headers['Content-Type'] = {
858
 
                     'js'   : 'text/javascript',
859
 
                     'css'  : 'text/css',
860
 
                     'png'  : 'image/png',
861
 
                     'gif'  : 'image/gif',
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()
873
 
 
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()
879
 
    return server
880
 
 
881
 
def stop_threaded_server(server):
882
 
    server.exit()
883
 
    server.thread = None
884
 
 
885
 
def option_parser():
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.')
893
 
    return parser
894
 
 
895
 
def daemonize(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'):
896
 
    try:
897
 
        pid = os.fork()
898
 
        if pid > 0:
899
 
            # exit first parent
900
 
            sys.exit(0)
901
 
    except OSError, e:
902
 
        print >>sys.stderr, "fork #1 failed: %d (%s)" % (e.errno, e.strerror)
903
 
        sys.exit(1)
904
 
 
905
 
    # decouple from parent environment
906
 
    os.chdir("/")
907
 
    os.setsid()
908
 
    os.umask(0)
909
 
 
910
 
    # do second fork
911
 
    try:
912
 
        pid = os.fork()
913
 
        if pid > 0:
914
 
            # exit from second parent
915
 
            sys.exit(0)
916
 
    except OSError, e:
917
 
        print >>sys.stderr, "fork #2 failed: %d (%s)" % (e.errno, e.strerror)
918
 
        sys.exit(1)
919
 
 
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())
927
 
 
928
 
 
929
 
 
930
 
def main(args=sys.argv):
931
 
    parser = option_parser()
932
 
    opts, args = parser.parse_args(args)
933
 
    if opts.daemonize and not iswindows:
934
 
        daemonize()
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)
944
 
    server.start()
945
 
    return 0
946
 
 
947
 
if __name__ == '__main__':
948
 
    sys.exit(main())