gallery.py: Use Python-3-compatible exception syntax
[blog.git] / posts / gallery / gallery.py
1 #!/usr/bin/env python
2 #
3 # Copyright (C) 2010-2013 W. Trevor King <wking@tremily.us>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 """
19 CGI gallery server for a picture directory organized along::
20
21   pics
22   |-- some_directory
23   |   |-- a_picture.jpg
24   |   |-- another_picture.jpg
25   |   |-- ...
26   |-- another_directory
27   |   |-- a_picture.jpg
28   |   |-- captioned_picture.jpg
29   |   |-- captioned_picture.jpg.txt
30   |-- ...
31
32 With::
33
34   pics$ gallery.py
35
36 Note that you can store a caption for ``<PICTURE>`` as plain text in
37 ``<PICTURE>.txt``.
38
39 See RFC 3875 for more details on the the Common Gateway Interface.
40
41 This script can also be run as a Simple Common Gateway Interface
42 (SCGI) with the ``--scgi`` option.
43 """
44
45 import logging as _logging
46 import logging.handlers as _logging_handlers
47 import math as _math
48 import mimetypes as _mimetypes
49 import os as _os
50 import os.path as _os_path
51 import random as _random
52 import re as _re
53 import subprocess as _subprocess
54 import urlparse as _urlparse
55 import xml.sax.saxutils as _xml_sax_saxutils
56
57
58 __version__ = '0.5'
59
60
61 IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.tif', '.tiff', '.png', '.gif']
62 VIDEO_EXTENSIONS = ['.mov', '.mp4', '.ogv']
63 STREAMING_TYPES = ['video/ogg']
64 RESPONSES = {  # httplib takes half a second to load
65     200: 'OK',
66     404: 'Not Found',
67     }
68
69 LOG = _logging.getLogger('gallery.py')
70 LOG.addHandler(_logging.StreamHandler())
71 #LOG.addHandler(_logging_handlers.SysLogHandler())
72 LOG.handlers[0].setFormatter(
73     _logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
74 LOG.setLevel(_logging.DEBUG)
75 #LOG.setLevel(_logging.WARNING)
76
77
78 class CommandError(Exception):
79     def __init__(self, command, status, stdout=None, stderr=None):
80         strerror = ['Command failed (%d):\n  %s\n' % (status, stderr),
81                     'while executing\n  %s' % str(command)]
82         super(CommandError, self).__init__('\n'.join(strerror))
83         self.command = command
84         self.status = status
85         self.stdout = stdout
86         self.stderr = stderr
87
88
89 class HTTPError(Exception):
90     def __init__(self, status, message=None, content=None):
91         if message is None:
92             message = RESPONSES[status]
93         super(HTTPError, self).__init__('{} {}'.format(status, message))
94         self.status = status
95         self.message = message
96         self.content = content
97
98
99 class ProcessingComplete(Exception):
100     pass
101
102
103 def invoke(args, stdin=None, stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
104            expect=(0,), cwd=None, encoding=None):
105     """
106     expect should be a tuple of allowed exit codes.  cwd should be
107     the directory from which the command will be executed.  When
108     unicode_output == True, convert stdout and stdin strings to
109     unicode before returing them.
110     """
111     if cwd == None:
112         cwd = '.'
113     LOG.debug('{}$ {}'.format(cwd, ' '.join(args)))
114     try :
115         q = _subprocess.Popen(args, stdin=_subprocess.PIPE, stdout=stdout,
116                               stderr=stderr, cwd=cwd)
117     except OSError as e:
118         raise CommandError(args, status=e.args[0], stderr=e)
119     stdout,stderr = q.communicate(input=stdin)
120     status = q.wait()
121     LOG.debug('{:d}\n{}{}'.format(status, stdout, stderr))
122     if status not in expect:
123         raise CommandError(args, status, stdout, stderr)
124     return status, stdout, stderr
125
126 def is_image(filename):
127     for extension in IMAGE_EXTENSIONS:
128         if filename.lower().endswith(extension):
129             return True
130     return False
131
132 def is_video(filename):
133     for extension in IMAGE_EXTENSIONS:
134         if filename.lower().endswith(extension):
135             return True
136     return False
137
138 def image_base(filename):
139     parts = filename.rsplit('.', 1)
140     assert len(parts) == 2, parts
141     return parts[0]
142
143
144 class CGIGalleryServer (object):
145     def __init__(self, base_path='.',
146                  base_url='/',
147                  cache_path='/tmp/gallery-cache/',
148                  serve_originals=True):
149         self._base_path = _os_path.abspath(base_path)
150         self._base_url = base_url
151         self._cache_path = cache_path
152         self._serve_originals = serve_originals
153         self._url_regexp = _re.compile('^[a-zA-Z0-9._/-]*$')
154         self._rows = 3
155         self._columns = 3
156         self.header = []
157         self.footer = []
158
159     def _http_header(self, mime='text/html', status=200):
160         msg = RESPONSES[status]
161         header = ['Status: {:d} {}'.format(status, msg)]
162         if mime.startswith('text/'):
163             charset = '; charset=UTF-8'
164         else:
165             charset = ''
166         header.append('Content-type: {}{}'.format(mime, charset))
167         return '\n'.join(header)
168
169     def _response(self, header=None, content='<h1>It works!</h1>',
170                   stream=None):
171         if header is None:
172             header = self._http_header()
173         stream.write(header)
174         stream.write('\n\n')
175         stream.write(content)
176         raise ProcessingComplete()
177
178     def _response_stream(self, header=None, content=None, stream=None,
179                          chunk_size=1024):
180         LOG.debug('streaming response')
181         if header is None:
182             header = self._http_header()
183         stream.write(header)
184         stream.write('\n\n')
185         stream.flush()  # flush headers
186         while True:
187             chunk = content.read(chunk_size)
188             if not chunk:
189                 break
190             stream.write(chunk)
191         raise ProcessingComplete()
192
193     def _error(self, status=404, content=None, stream=None):
194         header = self._http_header(status=status)
195         if content is None:
196             content = RESPONSES[status]
197         self._response(header=header, content=content, stream=stream)
198
199     def validate_url(self, url, exists=True, directory=False):
200         LOG.debug('validating {} (exists={}, directory={})'.format(
201                 repr(url), exists, directory))
202         if url is None:
203             return
204         elif (not self._url_regexp.match(url) or
205             url.startswith('/') or
206             '..' in url
207             ):
208             LOG.error('invalid url')
209             raise HTTPError(404)
210         if exists:
211             path = _os_path.join(self._base_path, url)
212             if directory:
213                 if not _os_path.isdir(path):
214                     LOG.error('nonexistent directory')
215                     raise HTTPError(404)
216             else:
217                 if not _os_path.isfile(path):
218                     raise HTTPError(404, 'nonexistent file')
219
220     def serve(self, url=None, page=0, stream=None):
221         LOG.info('serving url {} (page {})'.format(url, page))
222         try:
223             try:
224                 if url is None:
225                     self.index(stream=stream)
226                 elif url.endswith('random'):
227                     self.random(
228                         url=url, stream=stream, max_width=500, max_height=500)
229                 elif self.is_cacheable(url=url):
230                     self.validate_url(url=url, exists=False)
231                     self.cached(url=url, stream=stream)
232                 else:
233                     self.validate_url(url=url, exists=False, directory=True)
234                     self.page(url=url, page=page, stream=stream)
235                 raise HTTPError(404, 'unexpected URL type')
236             except HTTPError as e:
237                 LOG.error(e.message)
238                 self._error(e.status, content=e.content, stream=stream)
239         except ProcessingComplete:
240             pass
241
242     def page_from_query(self, query=None, query_string=None):
243         """Extract the requested page from a query string
244
245         This is a helper method for CGIGalleryServer consumers.
246         Specify either query or query_string, but not both.
247         """
248         if query is None:
249             query = _urlparse.parse_qs(query_string)
250         page = 0
251         if 'pp' in query:
252             pp = query['pp']
253             if isinstance(pp, list):
254                 pp = pp[0]
255             try:
256                 page = int(pp) - 1
257             except ValueError:
258                 pass
259         return page
260
261     def relative_url(self, url):
262         if url is None:
263             return url
264         if not url.startswith(self._base_url):
265             message = 'cannot convert {} to a relative URL of {}'.format(
266                 url, self._base_url)
267             raise HTTPError(404, message)
268         if url == self._base_url:
269             return None
270         return url[len(self._base_url):]
271
272     def _url(self, path):
273         relpath = _os_path.relpath(
274             _os_path.join(self._base_path, path), self._base_path)
275         if relpath == '.':
276             relpath = ''
277         elif path.endswith('/'):
278             relpath += '/'
279         return '{}{}'.format(self._base_url, relpath)
280
281     def _label(self, path):
282         dirname,base = _os_path.split(path)
283         if not base:  # directory path ending with '/'
284             dirname,base = _os_path.split(dirname)
285         return base.replace('_', ' ').title()
286
287     def _link(self, path, text=None):
288         if text is None:
289             text = self._label(path)
290         return '<a href="{}">{}</a>'.format(self._url(path), text)
291
292     def _subdirs(self, path):
293         try:
294             order = [d.strip() for d in
295                      open(_os_path.join(path, '_order')).readlines()]
296         except IOError:
297             order = []
298         dirs = sorted(_os.listdir(path))
299         start = []
300         for d in order:
301             if d in dirs:
302                 start.append(d)
303                 dirs.remove(d)
304         for d in start + dirs:
305             dirpath = _os_path.join(path, d)
306             if _os_path.isdir(dirpath):
307                 yield dirpath
308
309     def _images(self, path):
310         for p in sorted(_os.listdir(path)):
311             if p.startswith('.') or p.endswith('~'):
312                 continue
313             picture_path = _os_path.join(path, p)
314             if is_image(picture_path):
315                 yield picture_path
316
317     def index(self, stream=None):
318         LOG.debug('index page')
319         return self._directory(self._base_path, stream=stream)
320
321     def _original_url(self, url):
322         """Reverse thumbnail URL mapping
323
324         Returns (original_url, generating_callback, callback_kwargs).
325         """
326         base,extension = _os_path.splitext(url)
327         if extension in ['.png']:
328             try:
329                 root,width,height = base.rsplit('-', 2)
330             except ValueError:
331                 raise HTTPError(404, 'missing width/height in {}'.format(base))
332             try:
333                 width = int(width)
334                 height = int(height)
335             except ValueError as e:
336                 raise HTTPError(404, 'invalid width/height: {}'.format(e))
337             return (
338                 root + '.jpg',
339                 self._thumb, 
340                 {'max_width': width,
341                  'max_height': height},
342                 )
343         elif extension in VIDEO_EXTENSIONS:
344             return (
345                 base + '.mov',
346                 getattr(self, '_{}'.format(extension), None),
347                 {},
348                 )
349         raise HTTPError(404, 'no original URL for {}'.format(url))
350
351     def _thumb(self, image, max_width=None, max_height=None):
352         if not _os_path.exists(self._cache_path):
353             _os.makedirs(self._cache_path)
354         dirname,filename = _os_path.split(image)
355         reldir = _os_path.relpath(dirname, self._base_path)
356         cache_dir = _os_path.join(self._cache_path, reldir)
357         if not _os_path.isdir(cache_dir):
358             _os.makedirs(cache_dir)
359         extension = '-{:d}-{:d}.png'.format(max_width, max_height)
360         thumb_filename = image_base(filename)+extension
361         thumb_url = _os_path.join(dirname, thumb_filename)
362         thumb_path = _os_path.join(cache_dir, thumb_filename)
363         image_path = _os_path.join(self._base_path, image)
364         if not _os_path.isfile(image_path):
365             raise HTTPError(404, 'image path for thumbnail does not exist')
366         if (not _os_path.isfile(thumb_path)
367             or _os_path.getmtime(image_path) > _os_path.getmtime(thumb_path)):
368             invoke(['convert', '-format', 'png', '-strip', '-quality', '95',
369                     image_path,
370                     '-thumbnail', '{:d}x{:d}'.format(max_width, max_height),
371                     thumb_path])
372         return (thumb_path, self._url(thumb_url))
373
374     def _mp4(self, video, *args):
375         if not video.endswith('.mov'):
376             raise HTTPError(404, "can't translate {} to MPEGv4".format(video))
377         dirname,filename = _os_path.split(video)
378         mp4_filename = image_base(filename) + '.mp4'
379         reldir = _os_path.relpath(dirname, self._base_path)
380         cache_dir = _os_path.join(self._cache_path, reldir)
381         if not _os_path.isdir(cache_dir):
382             _os.makedirs(cache_dir)
383         mp4_url = _os_path.join(dirname, mp4_filename)
384         mp4_path = _os_path.join(cache_dir, mp4_filename)
385         if not _os_path.isfile(video):
386             raise HTTPError(404, 'source video path does not exist')
387         if (not _os_path.isfile(mp4_path)
388             or _os_path.getmtime(video) > _os_path.getmtime(mp4_path)):
389             arg = ['ffmpeg', '-i', video, '-acodec', 'libfaac', '-aq', '200',
390                    '-ac', '1', '-s', '640x480', '-vcodec', 'libx264',
391                    '-preset', 'slower', '-vpre', 'ipod640', '-b', '800k',
392                    '-bt', '800k', '-aspect', '640:480', '-threads', '0']
393             arg.extend(args)
394             arg.append(mp4_path)
395             invoke(arg)
396         return (mp4_path, self._url(mp4_url))
397
398     def _ogv(self, video, *args):
399         if not video.endswith('.mov'):
400             LOG.error("can't translate {} to Ogg Video".format(video))
401         dirname,filename = _os_path.split(video)
402         ogv_filename = image_base(filename) + '.ogv'
403         reldir = _os_path.relpath(dirname, self._base_path)
404         cache_dir = _os_path.join(self._cache_path, reldir)
405         if not _os_path.isdir(cache_dir):
406             _os.makedirs(cache_dir)
407         ogv_url = _os_path.join(dirname, ogv_filename)
408         ogv_path = _os_path.join(cache_dir, ogv_filename)
409         if not _os_path.isfile(video):
410             LOG.error('source video path does not exist')
411         if (not _os_path.isfile(ogv_path)
412             or _os_path.getmtime(video) > _os_path.getmtime(ogv_path)):
413             arg = ['ffmpeg2theora', '--optimize']
414             arg.extend(args)
415             arg.extend(['--output', ogv_path, video])
416             invoke(arg)
417         return (ogv_path, self._url(ogv_url))
418
419     def _get_image_caption(self, path):
420         caption_path = path + '.txt'
421         try:
422             return open(caption_path, 'r').read()
423         except IOError:
424             return None
425
426     def _get_image_video(self, path, fallback=None):
427         base_path = image_base(path)
428         for extension in VIDEO_EXTENSIONS:
429             video_path = base_path + extension
430             if _os_path.isfile(video_path):
431                 return self._video(video_path, fallback=fallback)
432         return None
433
434     def _captioned_video(self, path, href=None):
435         img = self._image(path, max_width=640, max_height=480)
436         caption = self._get_image_caption(path)
437         video = self._get_image_video(path, fallback=[img])
438         content = []
439         if video:
440             content.extend(video)
441             if href:
442                 content.append('<p>{}</p>'.format(
443                         self._link(path=href, text='gallery page')))
444         elif href:
445             content.append(self._link(path=href, text=img))
446         else:
447             content.append(img)
448         if caption:
449             caption = _xml_sax_saxutils.escape(caption)
450             content.append('<p>{}</p>'.format(caption))
451         return content
452
453     def _video(self, video, fallback=None, **kwargs):
454         if fallback is None:
455             fallback = [
456                 '<p>Your browser does not support the &lt;video&gt; tag, try',
457                 'downloading the video and playing it in an external player.',
458                 '</p>',
459                 ]
460         fallback = ['    '+line for line in fallback]
461         ogv_path,ogv_url = self._ogv(video)
462         mp4_path,mp4_url = self._mp4(video)
463         return [
464             '<p>',
465             ('  <video preloads="none" controls="controls" '
466              'width="640" height="480">'),
467             '    <source src="{}"'.format(mp4_url),
468             ('''            type='video/mp4; '''
469              '''codecs="avc1.42E01E, mp4a.40.2"' />'''),
470             '    <source src="{}"'.format(ogv_url),
471             '''            type='video/ogg; codecs="theora,vorbis"' />''',
472             ] + fallback + [
473             '  </video>',
474             '</p>',
475             '<p>Download as',
476             '  <a href="{}">Ogg/Theora/Vorbis</a> or'.format(ogv_url),
477             ('  <a href="{}">Mpeg4/H.264(ConstrainedBaselineProfile)/AAC</a>.'
478              ).format(mp4_url),
479             '<p>',
480             ]
481
482     def _image(self, image, **kwargs):
483         if kwargs:
484             image_path,image_url = self._thumb(image, **kwargs)
485         else:
486             image_url = image
487         sections = ['<img src="{}"'.format(image_url)]
488         caption = self._get_image_caption(path=image)
489         if caption:
490             caption = _xml_sax_saxutils.quoteattr(
491                 caption.replace('\n', ' ').strip())
492             sections.extend([
493                     'title={}'.format(caption),
494                     'alt={}'.format(caption),
495                     ])
496         sections.append('/>')
497         return ' '.join(sections)
498
499     def _image_page(self, image):
500         return image_base(image) + '/'
501
502     def random(self, url=None, stream=None, **kwargs):
503         LOG.debug('random image')
504         if url.endswith('/random'):
505             url = url[:(-len('/random'))]
506             self.validate_url(url=url, directory=True, stream=stream)
507             base_dir = _os_path.join(self._base_path, url)
508         elif url == 'random':
509             base_dir = self._base_path
510         else:
511             raise HTTPError(404)
512         images = []
513         for dirpath,dirnames,filenames in _os.walk(base_dir):
514             for filename in filenames:
515                 if is_image(filename):
516                     images.append(_os_path.join(dirpath, filename))
517         if not images:
518             self._response(content='<p>no images to choose from</p>',
519                            stream=stream)
520         image = _random.choice(images)
521         LOG.debug('selected random image {}'.format(image))
522         page = self._image_page(image)
523         content = self._captioned_video(path=image, href=page)
524         self._response(content='\n'.join(content), stream=stream)
525
526     def is_cacheable(self, url):
527         return is_image(url) or is_video(url)
528
529     def cached(self, url, stream=None):
530         LOG.debug('retrieving possibly cached item')
531         mime = _mimetypes.guess_type(url)[0]
532         if mime is None:
533             raise HTTPError(404, 'unknown mime type for {}'.format(url))
534         cache_path = _os_path.join(self._cache_path, url)
535         original_path = _os_path.join(self._base_path, url)
536         path = None
537         if _os_path.isfile(cache_path):
538             LOG.debug('return cached item {}'.format(cache_path))
539             path = cache_path
540         elif self._serve_originals and _os_path.isfile(original_path):
541             LOG.debug('return original item {}'.format(original_path))
542             path = original_path
543         else:
544             LOG.debug('possibly create cached item {}'.format(cache_path))
545             original_url,callback,kwargs = self._original_url(url)
546             original_path = _os_path.join(self._base_path, original_url)
547             if callback and _os_path.isfile(original_path):
548                 path,cache_url = callback(original_path, **kwargs)
549         if not path:
550             raise HTTPError(404)
551         try:
552             content = open(path, 'rb')
553         except IOError as e:
554             LOG.error(e)
555             raise HTTPError(404, 'item not found {}'.format(url))
556         header = self._http_header(mime=mime)
557         if mime in STREAMING_TYPES:
558             self._response_stream(
559                 header=header, content=content, stream=stream)
560         content = content.read()
561         self._response(header=header, content=content, stream=stream)
562
563     def page(self, url, page=0, stream=None):
564         LOG.debug('HTML page {} {}'.format(url, page))
565         if not url.endswith('/'):
566             raise HTTPError(404, 'HTML page URLs must end with a slash')
567         abspath = _os_path.join(self._base_path, url)
568         if _os_path.isdir(abspath):
569             self._directory(path=abspath, page=page, stream=stream)
570         for extension in IMAGE_EXTENSIONS:
571             file_path = abspath[:-1] + extension
572             if _os_path.isfile(file_path):
573                 self._page(path=file_path, stream=stream)
574         raise HTTPError(404, 'unknown HTML page {}'.format(url))
575
576     def _directory_header(self, path):
577         relpath = _os_path.relpath(path, self._base_path)
578         crumbs = []
579         dirname = relpath
580         while dirname:
581             dirname,base = _os_path.split(dirname)
582             if base != '.':
583                 crumbs.insert(0, base)
584         crumbs.insert(0, '')
585         links = [None] * len(crumbs)
586         for i,c in enumerate(crumbs):
587             if i < len(crumbs)-1:
588                 if i == 0:
589                     links[i] = self._link(self._base_path, 'Gallery')
590                 else:
591                     relpath = '/'.join(crumbs[1:i+1]) + '/'
592                     fullpath = _os_path.join(self._base_path, relpath)
593                     links[i] = self._link(path=fullpath)
594             else:
595                 if i == 0:
596                     links[i] = 'Gallery'
597                 else:
598                     links[i] = self._label(crumbs[i])
599         content = ['<h1>{}</h1>'.format(' '.join(links))]
600         return content
601
602     def _directory_page_navigation(self, path, page, pages):
603         if pages <= 1:
604             return []
605         prev_page = path + '?pp={:d}'.format((page - 1) % pages + 1)
606         next_page = path + '?pp={:d}'.format((page + 1) % pages + 1)
607         return [
608             '<div style="text-align: center;">',
609             '<p>',
610             self._link(prev_page, 'previous'),
611             '({:d} of {:d})'.format(page+1, pages),
612             self._link(next_page, 'next'),
613             '</p>',
614             '</div>', 
615            ]
616
617     def _directory_subdirs(self, path):
618         content = []
619         dirs = list(self._subdirs(path))
620         if dirs:
621             content.append('<ul>')
622             for d in dirs:
623                 content.append('  <li>{}</li>'.format(self._link(d+'/')))
624             content.append('</ul>')
625         return content
626
627     def _directory_images(self, path, images):
628         content = ['<table style="margin-left: auto; margin-right: auto;">']
629         column = 0
630         for image in images:
631             page = self._image_page(image)
632             img = self._image(image, max_width=300, max_height=300)
633             link = self._link(page, img)
634             if column == 0:
635                 content.append('  <tr>')
636             content.extend([
637                     '    <td style="text-align: center;">',
638                     '      {}'.format(link),
639                     '    </td>',
640                     ])
641             column += 1
642             if column == self._columns:
643                 content.append('  </tr>')
644                 column = 0
645         if column != 0:
646             #content.extend()
647             content.append('  </tr>')
648         content.append('</table>')
649         return content
650
651     def _directory(self, path, page=0, stream=None):
652         LOG.debug('directory page {} {}'.format(path, page))
653         images = list(self._images(path))
654         images_per_page = self._rows * self._columns
655         pages = int(_math.ceil(float(len(images)) / images_per_page)) or 1
656         if page < 0 or page >= pages:
657             raise HTTPError(
658                 404,
659                 'page out of bounds for this gallery 0 <= {:d} < {:d}'.format(
660                     page, pages))
661         first_image = images_per_page * page
662         images = images[first_image:first_image+images_per_page]
663         content = []
664         content.extend(self.header)
665         content.extend(self._directory_header(path))
666         nav = self._directory_page_navigation(path, page=page, pages=pages)
667         content.extend(nav)
668         content.extend(self._directory_subdirs(path))
669         content.extend(self._directory_images(path, images=images))
670         content.extend(nav)
671         content.extend(self.footer)
672         self._response(content='\n'.join(content), stream=stream)
673
674     def _page(self, path, stream=None):
675         LOG.debug('image page {}'.format(path))
676         gallery = _os_path.dirname(path)
677         images = list(self._images(gallery))
678         images_per_page = self._rows * self._columns
679         i = images.index(path)
680         page = i / images_per_page
681         gallery_page = '{}/?pp={:d}'.format(gallery, page + 1)
682         prev_page = self._image_page(images[i - 1])
683         next_page = self._image_page(images[(i + 1) % len(images)])
684         content = []
685         content.extend(self.header)
686         content.extend([
687                 '<div style="text-align: center;">',
688                 '<p>',
689                 self._link(prev_page, 'previous'),
690                 self._link(gallery_page, 'all'),
691                 self._link(next_page, 'next'),
692                 '</p>',
693                 ])
694         content.extend(self._captioned_video(path))
695         content.append('</div>')
696         content.extend(self.footer)
697         self._response(content='\n'.join(content), stream=stream)
698
699
700 def serve_cgi(server):
701     import cgi
702     import cgitb
703     import sys
704
705     url=None
706     cgitb.enable()
707     #cgitb.enable(display=0, logdir="/tmp/")
708     data = cgi.FieldStorage()
709     page = server.page_from_query(
710         query={key: data[key].getlist() for key in data.keys()})
711     if 'p' in data:
712         p = data['p']
713         if isinstance(p, list):
714             p = p[0]
715         url = p.value
716     server.serve(url=url, page=page, stream=sys.stdout)
717
718 def serve_scgi(server, host='localhost', port=4000):
719     import scgi
720     import scgi.scgi_server
721
722     class GalleryHandler(scgi.scgi_server.SCGIHandler):
723         def produce(self, env, bodysize, input, output):
724             #LOG.info(HTTP_USER_AGENT REQUEST_METHOD REMOTE_ADDR REQUEST_URI
725             url = env.get('DOCUMENT_URI', None)
726             page = server.page_from_query(
727                 query_string=env.get('QUERY_STRING', ''))
728             try:
729                 try:
730                     url = server.relative_url(url=url)
731                 except HTTPError as e:
732                     LOG.error(e.message)
733                     server._error(e.status, content=e.content, stream=output)
734             except ProcessingComplete:
735                 pass
736             else:
737                 server.serve(url=url, page=page, stream=output)
738
739     s = scgi.scgi_server.SCGIServer(
740         handler_class=GalleryHandler, host=host, port=port)
741     LOG.info('serving SCGI on {}:{}'.format(host, port))
742     s.serve()
743
744
745 if __name__ == '__main__':
746     import argparse as _argparse
747
748     parser = _argparse.ArgumentParser(
749         description=__doc__, version=__version__,
750         formatter_class=_argparse.RawDescriptionHelpFormatter)
751     parser.add_argument(
752         '--scgi', default=False, action='store_const', const=True,
753         help='Run as a SCGI server (vs. serving a single CGI call)')
754     parser.add_argument(
755         '--port', default=4000, type=int,
756         help='Port to listen to (if runing as a SCGI server)')
757     parser.add_argument(
758         '--base-path', default='.',
759         help='Path to the root gallery source')
760     parser.add_argument(
761         '--base-url', default='/',
762         help='URL for the root gallery source')
763     parser.add_argument(
764         '--shared-path', default=None,
765         help=('Optional path to the shared directory containing '
766               '`header.shtml` and `footer.shtml`'))
767     parser.add_argument(
768         '--cache-path', default='/tmp/gallery-cache',
769         help='Path to the thumbnail and movie cache directory')
770
771     args = parser.parse_args()
772
773     s = CGIGalleryServer(
774         base_path=args.base_path, base_url=args.base_url,
775         cache_path=args.cache_path)
776     if args.shared_path:
777         shared = args.shared_path
778         s.header = [open(_os_path.join(shared, 'header.shtml'), 'r').read()]
779         s.footer = [open(_os_path.join(shared, 'footer.shtml'), 'r').read()]
780
781     if args.scgi:
782         serve_scgi(server=s, port=args.port)
783     else:
784         serve_cgi(server=s)