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