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