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