Update gallery.py to run as a CGI server (bump to v0.4).
authorW. Trevor King <wking@drexel.edu>
Tue, 21 Feb 2012 15:30:46 +0000 (10:30 -0500)
committerW. Trevor King <wking@drexel.edu>
Tue, 21 Feb 2012 15:36:48 +0000 (10:36 -0500)
posts/gallery/gallery.py

index 0db8ffa89339b72eb6e80a314ed19d4673be3813..b84a0cda00357ad35f86a46c156831aa90788e21 100755 (executable)
@@ -1,6 +1,6 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 #
-# Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
+# Copyright (C) 2010-2011 W. Trevor King <wking@drexel.edu>
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -17,7 +17,7 @@
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
 """
-Generate HTML gallery pages for a picture directory organized along::
+CGI gallery server for a picture directory organized along::
 
   pics
   |-- some_directory
@@ -37,39 +37,50 @@ With::
 Note that you can store a caption for ``<PICTURE>`` as plain text in
 ``<PICTURE>.txt``.
 
-The resulting gallery pages will be::
-
-  pics
-  |-- some_directory
-  |   |-- a_picture.jpg
-  |   |-- another_picture.jpg
-  |   |-- ...
-  |   |-- <INDEX> -> <GALLERY-1>
-  |   |-- <GALLERY-1>
-  |   |-- <GALLERY-2>
-  |   |-- <GALLERY-...>
-  |   |-- <THUMBDIR>
-  |   |   |-- a_picture.png
-  |   |   |-- another_picture.png
-  |   |   |-- ...
-  |   |-- <PAGEDIR>
-  |   |   |-- a_picture.html
-  |   |   |-- another_picture.html
-  |   |   |-- ...
-  |-- ...
-
-So you'll probably want to symlink index.html to <GALLERY-1>.
+See RFC 3875 for more details on the the Common Gateway Interface.
 """
 
+# import logging first so we can timestamp other module imports
 import logging
+
+
+LOG = logging.getLogger('gallery')
+_ch = logging.StreamHandler()
+_ch.setLevel(logging.DEBUG)
+_formatter = logging.Formatter(
+    '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+_ch.setFormatter(_formatter)
+LOG.addHandler(_ch)
+#LOG.setLevel(logging.DEBUG)
+LOG.setLevel(logging.WARNING)
+
+LOG.debug('importing math')
+import math
+LOG.debug('importing os')
 import os
+LOG.debug('importing os.path')
 import os.path
+LOG.debug('importing random')
+import random
+LOG.debug('importing re')
+import re
+LOG.debug('importing subprocess')
 from subprocess import Popen, PIPE
+LOG.debug('importing sys')
 import sys
 
 
-__version__ = '0.3'
-LOG = logging
+LOG.debug('parsing class')
+
+
+__version__ = '0.4'
+
+IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.tif', '.tiff', '.png', '.gif']
+VIDEO_EXTENSIONS = ['.mov', '.mp4', '.ogv']
+RESPONSES = {  # httplib takes half a second to load
+    200: 'OK',
+    404: 'Not Found',
+    }
 
 
 class CommandError(Exception):
@@ -92,223 +103,531 @@ def invoke(args, stdin=None, stdout=PIPE, stderr=PIPE, expect=(0,),
     """
     if cwd == None:
         cwd = '.'
-    LOG.debug('%s$ %s' % (cwd, ' '.join(args)))
+    LOG.debug('{}$ {}'.format(cwd, ' '.join(args)))
     try :
         q = Popen(args, stdin=PIPE, stdout=stdout, stderr=stderr, cwd=cwd)
     except OSError, e:
         raise CommandError(args, status=e.args[0], stderr=e)
     stdout,stderr = q.communicate(input=stdin)
     status = q.wait()
-    LOG.debug('%d\n%s%s' % (status, stdout, stderr))
+    LOG.debug('{:d}\n{}{}'.format(status, stdout, stderr))
     if status not in expect:
         raise CommandError(args, status, stdout, stderr)
     return status, stdout, stderr
 
 def is_picture(filename):
-    for extension in ['.jpg', '.jpeg', '.tif', '.tiff', '.png', '.gif']:
+    for extension in IMAGE_EXTENSIONS:
         if filename.lower().endswith(extension):
             return True
     return False
 
-def picture_base(filename):
+def image_base(filename):
     parts = filename.rsplit('.', 1)
     assert len(parts) == 2, parts
     return parts[0]
 
-def pictures(picdir):
-    return sorted([p for p in os.listdir(picdir) if is_picture(p)])
-
-def make_thumbs(picdir, pictures, thumbdir, max_height, max_width,
-                force=False):
-    fullthumbdir = os.path.join(picdir, thumbdir)
-    if not os.path.exists(fullthumbdir):
-        os.mkdir(fullthumbdir)
-    if force == False:
-        new_pictures = []
-        for p in pictures:
-            thumb_p = os.path.join(picdir, thumbdir, picture_base(p)+'.png')
-            if (not os.path.exists(thumb_p)
-                or os.path.getmtime(p) > os.path.getmtime(thumb_p)):
-                new_pictures.append(p)
-    if len(new_pictures) > 0:
-        log.info('  making %d thumbnails for %s' % (len(new_pictures), picdir))
-        invoke(['mogrify', '-format', 'png', '-strip', '-quality', '95',
-                '-path', thumbdir,
-                '-thumbnail', '%dx%d' % (max_width, max_height),
-                ]+new_pictures, cwd=picdir)
-    return [os.path.join(thumbdir, picture_base(p)+'.png')
-            for p in pictures]
-
-def page_header():
-    return '\n'.join([
-            '<html>',
-            '<body>', ''])
-
-def page_footer():
-    return '\n'.join([
-            '</body>',
-            '</html>', ''])
-
-def pagename(pic):
-    return picture_base(pic) + '.html'
-
-def make_page(picdir, gallery, pagedir, pic,
-              previous_pic=None, next_pic=None, force=False):
-    fullpagedir = os.path.join(picdir, pagedir)
-    name = pagename(pic)
-    if not os.path.exists(fullpagedir):
-        os.mkdir(fullpagedir)
-    page_p = os.path.join(fullpagedir, name)
-    captionfile = os.path.join(picdir, pic+'.txt')
-    if (not force
-        and os.path.exists(page_p)
-        and ((not os.path.exists(captionfile))
-             or (os.path.exists(captionfile)
-                 and os.path.getmtime(captionfile) < os.path.getmtime(page_p)))):
-        LOG.info('  skip page for %s' % page_p)
-        return os.path.join(pagedir, name)
-    LOG.info('  make page for %s' % page_p)
-    p = open(page_p, 'w')
-    p.write(page_header())
-    if os.path.exists(captionfile):
-        caption = open(captionfile, 'r').read()
-        LOG.debug('    found caption %s' % captionfile)
-    else:
-        caption = None
-    p.write('<div style="align: center; text-align: center;">\n')
-    if previous_pic != None:
-        p.write('<a href="./%s">previous</a>\n' % pagename(previous_pic))
-    p.write('<a href="../%s">all</a>\n' % gallery)
-    if next_pic != None:
-        p.write('<a href="./%s">next</a>\n' % pagename(next_pic))
-    p.write('<br />\n')
-    p.write('<img width="600" src="../%s" />\n' % pic)
-    if caption != None:
-        p.write('<p>%s</p>\n' % caption)
-    p.write('</div>\n')
-    p.write(page_footer())
-    return os.path.join(pagedir, name)
-
-def gallery_header(gallery_page_index=None):
-    return '\n'.join([
-            '<html>',
-            '<body>',''])
-
-def gallery_footer(gallery_page_index=None):
-    return '\n'.join([
-            '</body>',
-            '</html>',''])
-
-def make_gallery(picdir, index, gallery, pagedir, thumbdir,
-                 cols, rows, height, width,
-                 up_link=None, force=False):
-    LOG.info('make gallery for %s' % picdir)
-    pics = pictures(picdir)
-    thumbs = make_thumbs(picdir, pics, thumbdir, height, width, force=force)
-    pages = []
-    if os.path.exists(os.path.join(picdir, gallery)) and force == False:
-        return
-    pic_thumbs = zip(pics, thumbs)
-    i = 0
-    gallery_i = 1 # one-indexed
-    g = None
-    while i < len(pic_thumbs):
-        if g == None:
-            gallery_page = os.path.join(picdir, gallery % gallery_i))
-            LOG.info('  write gallery page %s' % gallery_page)
-            g = open(gallery_page, 'w')
-            g.write(gallery_header(gallery_i))
-            if up_link != None:
-                up_html = '<a href="../">%s</a>\n' % up_link
-            else:
-                up_html = ''
-            if gallery_i > 1:
-                prev_url = gallery % (gallery_i-1)
-                prev_html = '<a href="./%s">previous</a>\n' % prev_url
-            else:
-                prev_html = ''
-            if i + rows*cols < len(pic_thumbs):
-                next_url = gallery % (gallery_i+1)
-                next_html = '<a href="./%s">next</a>\n' % next_url
+
+class CGIGalleryServer (object):
+    def __init__(self, base_path='/var/www/localhost/htdocs/gallery/',
+                 base_url='/cgi-bin/gallery.py',
+                 cache_path='/tmp/gallery-cache/'):
+        self._base_path = base_path
+        self._base_url = base_url
+        self._cache_path = cache_path
+        self._url_regexp = re.compile('^[a-z0-9._/-]*$')
+        self._rows = 3
+        self._columns = 3
+        self.header = []
+        self.footer = []
+
+    def _http_header(self, mime='text/html', status=200):
+        msg = RESPONSES[status]
+        header = ['Status: {:d} {}'.format(status, msg)]
+        if mime.startswith('text/'):
+            charset = '; charset=UTF-8'
+        else:
+            charset = ''
+        header.append('Content-type: {}{}'.format(mime, charset))
+        return '\n'.join(header)
+
+    def _response(self, header=None, content='<h1>It works!</h1>'):
+        if header is None:
+            header = self._http_header()
+        sys.stdout.write(header)
+        sys.stdout.write('\n\n')
+        sys.stdout.write(content)
+        sys.exit(0)
+
+    def _response_stream(self, header=None, content=None, chunk_size=1024):
+        LOG.debug('streaming response')
+        if header is None:
+            header = self._http_header()
+        sys.stdout.write(header)
+        sys.stdout.write('\n\n')
+        sys.stdout.flush()  # flush headers
+        while True:
+            chunk = content.read(chunk_size)
+            if not chunk:
+                break
+            sys.stdout.write(chunk)
+        sys.exit(0)
+
+    def _error(self, status=404, content=None):
+        header = self._http_header(status=status)
+        if content is None:
+            content = RESPONSES[status]
+        self._response(header=header, content=content)
+
+    def validate_url(self, url):
+        if url is None:
+            return
+        elif (not self._url_regexp.match(url) or
+            url.startswith('/') or
+            '..' in url
+            ):
+            LOG.error('invalid url')
+            self._error(404)
+        path = os.path.join(self._base_path, url)
+        if os.path.exists(path) and not os.path.isdir(path):
+            LOG.error('nonexstand directory')
+            self._error(404)
+
+    def serve(self, url, page=0):
+        LOG.info('serving url {} (page {})'.format(url, page))
+        if url is None:
+            self.index()
+        elif url.endswith('random'):
+            self.random(url=url, max_width=500, max_height=500)
+        elif self.is_cached(url=url):
+            self.cached(url=url)
+        elif url.endswith('.png'):
+            self.thumb(url=url)
+        else:
+            self.page(url=url, page=page)
+            self.validate_url(url=url)
+        LOG.error('unexpected url type')
+        self._error(404)
+
+    def _url(self, path):
+        relpath = os.path.relpath(
+            os.path.join(self._base_path, path), self._base_path)
+        if relpath == '.':
+            relpath = ''
+        elif path.endswith('/'):
+            relpath += '/'
+        return '{}{}'.format(self._base_url, relpath)
+
+    def _label(self, path):
+        dirname,base = os.path.split(path)
+        if not base:  # directory path ending with '/'
+            dirname,base = os.path.split(dirname)
+        return base.replace('_', ' ').title()
+
+    def _link(self, path, text=None):
+        if text is None:
+            text = self._label(path)
+        return '<a href="{}">{}</a>'.format(self._url(path), text)
+
+    def _subdirs(self, path):
+        try:
+            order = [d.strip() for d in
+                     open(os.path.join(path, '_order')).readlines()]
+        except IOError:
+            order = []
+        dirs = sorted(os.listdir(path))
+        start = []
+        for d in order:
+            if d in dirs:
+                start.append(d)
+                dirs.remove(d)
+        for d in start + dirs:
+            dirpath = os.path.join(path, d)
+            if os.path.isdir(dirpath):
+                yield dirpath
+
+    def _images(self, path):
+        for p in sorted(os.listdir(path)):
+            if p.startswith('.') or p.endswith('~'):
+                continue
+            picture_path = os.path.join(path, p)
+            if is_picture(picture_path):
+                yield picture_path
+
+    def index(self):
+        LOG.debug('index page')
+        return self._directory(self._base_path)
+
+    def _thumb(self, image, max_width=None, max_height=None):
+        if not os.path.exists(self._cache_path):
+            os.makedirs(self._cache_path)
+        dirname,filename = os.path.split(image)
+        reldir = os.path.relpath(dirname, self._base_path)
+        cache_dir = os.path.join(self._cache_path, reldir)
+        if not os.path.isdir(cache_dir):
+            os.makedirs(cache_dir)
+        extension = '-{:d}-{:d}.png'.format(max_width, max_height)
+        thumb_filename = image_base(filename)+extension
+        thumb_url = os.path.join(dirname, thumb_filename)
+        thumb_path = os.path.join(cache_dir, thumb_filename)
+        image_path = os.path.join(self._base_path, image)
+        if not os.path.isfile(image_path):
+            LOG.error('image path for thumbnail does not exist')
+            return self._error(404)
+        if (not os.path.isfile(thumb_path)
+            or os.path.getmtime(image_path) > os.path.getmtime(thumb_path)):
+            invoke(['convert', '-format', 'png', '-strip', '-quality', '95',
+                    image_path,
+                    '-thumbnail', '{:d}x{:d}'.format(max_width, max_height),
+                    thumb_path])
+        return thumb_url
+
+    def _mp4(self, video, *args):
+        if not video.endswith('.mov'):
+            LOG.error("can't translate {} to MPEGv4".format(video))
+        dirname,filename = os.path.split(video)
+        mp4_filename = image_base(filename) + '.mp4'
+        reldir = os.path.relpath(dirname, self._base_path)
+        cache_dir = os.path.join(self._cache_path, reldir)
+        if not os.path.isdir(cache_dir):
+            os.makedirs(cache_dir)
+        mp4_url = os.path.join(dirname, mp4_filename)
+        mp4_path = os.path.join(cache_dir, mp4_filename)
+        if not os.path.isfile(video):
+            LOG.error('source video path does not exist')
+            return self._error(404)
+        if (not os.path.isfile(mp4_path)
+            or os.path.getmtime(video) > os.path.getmtime(mp4_path)):
+            arg = ['ffmpeg', '-i', video, '-acodec', 'libfaac', '-aq', '200',
+                   '-ac', '1', '-s', '640x480', '-vcodec', 'libx264',
+                   '-preset', 'slower', '-vpre', 'ipod640', '-b', '800k',
+                   '-bt', '800k', '-aspect', '640:480', '-threads', '0']
+            arg.extend(args)
+            arg.append(mp4_path)
+            invoke(arg)
+        return self._url(mp4_url)
+
+    def _ogv(self, video, *args):
+        if not video.endswith('.mov'):
+            LOG.error("can't translate {} to Ogg Video".format(video))
+        dirname,filename = os.path.split(video)
+        ogv_filename = image_base(filename) + '.ogv'
+        reldir = os.path.relpath(dirname, self._base_path)
+        cache_dir = os.path.join(self._cache_path, reldir)
+        if not os.path.isdir(cache_dir):
+            os.makedirs(cache_dir)
+        ogv_url = os.path.join(dirname, ogv_filename)
+        ogv_path = os.path.join(cache_dir, ogv_filename)
+        if not os.path.isfile(video):
+            LOG.error('source video path does not exist')
+            return self._error(404)
+        if (not os.path.isfile(ogv_path)
+            or os.path.getmtime(video) > os.path.getmtime(ogv_path)):
+            arg = ['ffmpeg2theora', '--optimize']
+            arg.extend(args)
+            arg.extend(['--output', ogv_path, video])
+            invoke(arg)
+        return self._url(ogv_url)
+
+    def _get_image_caption(self, path):
+        caption_path = path + '.txt'
+        try:
+            return open(caption_path, 'r').read()
+        except IOError:
+            return None
+
+    def _get_image_video(self, path, fallback=None):
+        base_path = image_base(path)
+        for extension in VIDEO_EXTENSIONS:
+            video_path = base_path + extension
+            if os.path.isfile(video_path):
+                return self._video(video_path, fallback=fallback)
+        return None
+
+    def _captioned_video(self, path, href=None):
+        img = self._image(path, max_width=640, max_height=480)
+        caption = self._get_image_caption(path)
+        video = self._get_image_video(path, fallback=[img])
+        content = []
+        if video:
+            content.extend(video)
+            if href:
+                content.append('<p>{}</p>'.format(
+                        self._link(path=href, text='gallery page')))
+        elif href:
+            content.append(self._link(path=href, text=img))
+        else:
+            content.append(img)
+        if caption:
+            content.append('<p>{}</p>'.format(caption))
+        return content
+
+    def _video(self, video, fallback=None, **kwargs):
+        if fallback is None:
+            fallback = [
+                '<p>Your browser does not support the &lt;video&gt; tag, try',
+                'downloading the video and playing it in an external player.',
+                '</p>',
+                ]
+        fallback = ['    '+line for line in fallback]
+        ogv = self._ogv(video)
+        mp4 = self._mp4(video)
+        return [
+            '<p>',
+            ('  <video preloads="none" controls="controls" '
+             'width="640" height="480">'),
+            '    <source src="{}"'.format(mp4),
+            ('''            type='video/mp4; '''
+             '''codecs="avc1.42E01E, mp4a.40.2"' />'''),
+            '    <source src="{}"'.format(ogv),
+            '''            type='video/ogg; codecs="theora,vorbis"' />''',
+            ] + fallback + [
+            '  </video>',
+            '</p>',
+            '<p>Download as',
+            '  <a href="{}">Ogg/Theora/Vorbis</a> or'.format(ogv),
+            ('  <a href="{}">Mpeg4/H.264(ConstrainedBaselineProfile)/AAC</a>.'
+             ).format(mp4),
+            '<p>',
+            ]
+
+    def _image(self, image, **kwargs):
+        if kwargs:
+            image = self._thumb(image, **kwargs)
+        return '<img src="{}" />'.format(self._url(image))
+
+    def _image_page(self, image):
+        return image_base(image) + '/'
+
+    def random(self, url=None, **kwargs):
+        LOG.debug('random image')
+        if url.endswith('/random'):
+            base_dir = os.path.join(
+                self._base_path, url[:(-len('/random'))])
+        elif url == 'random':
+            base_dir = self._base_path
+        else:
+            self._error(404)
+        images = []
+        for dirpath,dirnames,filenames in os.walk(base_dir):
+            for filename in filenames:
+                if is_picture(filename):
+                    images.append(os.path.join(dirpath, filename))
+        if not images:
+            self._response(content='<p>no images to choose from</p>')
+        image = random.choice(images)
+        LOG.debug('selected random image {}'.format(image))
+        page = self._image_page(image)
+        content = self._captioned_video(path=image, href=page)
+        self._response(content='\n'.join(content))
+
+    def is_cached(self, url):
+        for extension in ['.png', '.mp4', '.ogv']:
+            if url.endswith(extension):
+                return True
+        return False
+
+    def cached(self, url):
+        LOG.debug('retrieving cached item')
+        if url.endswith('.png'):
+            mime = 'image/png'
+        elif url.endswith('.ogv'):
+            mime = 'video/ogg'
+        elif url.endswith('.mp4'):
+            mime = 'video/mp4'
+        else:
+            raise NotImplementedError()
+        header = self._http_header(mime=mime)
+        cache_path = os.path.join(self._cache_path, url)
+        try:
+            stream = open(cache_path, 'rb')
+        except IOError, e:
+            LOG.error('invalid url')
+            LOG.error(e)
+            self._error(404)
+        if mime in ['video/ogg']:
+            self._response_stream(header=header, content=stream)
+        content = stream.read()
+        self._response(header=header, content=content)
+
+    def page(self, url, page=0):
+        LOG.debug('HTML page')
+        if not url.endswith('/'):
+            LOG.error('HTML page URLs must end with a slash')
+            self._error(404)
+        abspath = os.path.join(self._base_path, url)
+        if os.path.isdir(abspath):
+            self._directory(path=abspath, page=page)
+        for extension in IMAGE_EXTENSIONS:
+            file_path = abspath[:-1] + extension
+            if os.path.isfile(file_path):
+                self._page(path=file_path)
+        LOG.debug('unknown HTML page')
+        self._error(404)
+
+    def _directory_header(self, path):
+        relpath = os.path.relpath(path, self._base_path)
+        crumbs = []
+        dirname = relpath
+        while dirname:
+            dirname,base = os.path.split(dirname)
+            if base != '.':
+                crumbs.insert(0, base)
+        crumbs.insert(0, '')
+        links = [None] * len(crumbs)
+        for i,c in enumerate(crumbs):
+            if i < len(crumbs)-1:
+                if i == 0:
+                    links[i] = self._link(self._base_path, 'Gallery')
+                else:
+                    relpath = '/'.join(crumbs[1:i+1]) + '/'
+                    fullpath = os.path.join(self._base_path, relpath)
+                    links[i] = self._link(path=fullpath)
             else:
-                next_html = ''
-            g.write('<div style="align: center; text-align: center;">\n')
-            g.write('<p>%s%s%s</p>\n' % (prev_html, up_html, next_html))
-            g.write('<table>\n')
-            column = 0
-            row = 0
-        LOG.info('placing picture %d of %d' % (i+1, len(pic_thumbs)))
-        pic,thumb = pic_thumbs[i]
-        prev = next = None
-        if i > 0:
-            prev = pics[i-1]
-        if i+1 < len(pics):
-            next = pics[i+1]
-        page = make_page(picdir, gallery % gallery_i, pagedir, pic, prev, next,
-                         force=force)
-        if column == 0:
-            g.write('  <tr>\n')
-        g.write('    <td style="text-align: center">\n')
-        g.write('      <a href="%s" style="border: 0">\n' % page)
-        g.write('        <img alt="%s" src="%s"\n' % (pic, thumb))
-        g.write('      </a>\n')
-        g.write('    </td>\n')
-        column += 1
-        if column == cols:
-            g.write('  </tr>\n')
-            column = 0
-            row += 1
-        if row == rows or i+1 == len(pic_thumbs):
-            g.write('</table>\n')
-            g.write('</div>\n')
-            g.write(gallery_footer(gallery_i))
-            g.close()
-            g = None
-            gallery_i += 1
-        i += 1
-    if i > 0 and not os.path.exists(index):
-        os.symlink(gallery % 1, index)
+                if i == 0:
+                    links[i] = 'Gallery'
+                else:
+                    links[i] = self._label(crumbs[i])
+        content = ['<h1>{}</h1>'.format(' '.join(links))]
+        return content
+
+    def _directory_page_navigation(self, path, page, pages):
+        if pages <= 1:
+            return []
+        prev_page = path + '?pp={:d}'.format((page - 1) % pages + 1)
+        next_page = path + '?pp={:d}'.format((page + 1) % pages + 1)
+        return [
+            '<div style="text-align: center;">',
+            '<p>',
+            self._link(prev_page, 'previous'),
+            '({:d} of {:d})'.format(page+1, pages),
+            self._link(next_page, 'next'),
+            '</p>',
+            '</div>', 
+           ]
+
+    def _directory_subdirs(self, path):
+        content = []
+        dirs = list(self._subdirs(path))
+        if dirs:
+            content.append('<ul>')
+            for d in dirs:
+                content.append('  <li>{}</li>'.format(self._link(d+'/')))
+            content.append('</ul>')
+        return content
+
+    def _directory_images(self, path, images):
+        content = ['<table style="margin-left: auto; margin-right: auto;">']
+        column = 0
+        for image in images:
+            page = self._image_page(image)
+            img = self._image(image, max_width=300, max_height=300)
+            link = self._link(page, img)
+            if column == 0:
+                content.append('  <tr>')
+            content.extend([
+                    '    <td style="text-align: center;">',
+                    '      {}'.format(link),
+                    '    </td>',
+                    ])
+            column += 1
+            if column == self._columns:
+                content.append('  </tr>')
+                column = 0
+        if column != 0:
+            #content.extend()
+            content.append('  </tr>')
+        content.append('</table>')
+        return content
+
+    def _directory(self, path, page=0):
+        LOG.debug('directory page')
+        images = list(self._images(path))
+        images_per_page = self._rows * self._columns
+        pages = int(math.ceil(float(len(images)) / images_per_page)) or 1
+        if page < 0 or page >= pages:
+            LOG.error(
+                'page out of bounds for this gallery 0 <= {:d} < {:d}'.format(
+                    page, pages))
+            self._error(404)
+        first_image = images_per_page * page
+        images = images[first_image:first_image+images_per_page]
+        content = []
+        content.extend(self.header)
+        content.extend(self._directory_header(path))
+        nav = self._directory_page_navigation(path, page=page, pages=pages)
+        content.extend(nav)
+        content.extend(self._directory_subdirs(path))
+        content.extend(self._directory_images(path, images=images))
+        content.extend(nav)
+        content.extend(self.footer)
+        self._response(content='\n'.join(content))
+
+    def _page(self, path):
+        LOG.debug('image page')
+        gallery = os.path.dirname(path)
+        images = list(self._images(gallery))
+        images_per_page = self._rows * self._columns
+        i = images.index(path)
+        page = i / images_per_page
+        gallery_page = '{}/?pp={:d}'.format(gallery, page + 1)
+        prev_page = self._image_page(images[i - 1])
+        next_page = self._image_page(images[(i + 1) % len(images)])
+        content = []
+        content.extend(self.header)
+        content.extend([
+                '<div style="text-align: center;">',
+                '<p>',
+                self._link(prev_page, 'previous'),
+                self._link(gallery_page, 'all'),
+                self._link(next_page, 'next'),
+                '</p>',
+                ])
+        content.extend(self._captioned_video(path))
+        content.append('</div>')
+        content.extend(self.footer)
+        self._response(content='\n'.join(content))
 
 
 if __name__ == '__main__':
-    import optparse
-    parser = optparse.OptionParser(usage='%prog [options] PICTURE-DIR ...',
-                                   epilog=__doc__)
-    parser.format_epilog = lambda formatter : __doc__
-    parser.add_option('--index', default='index.html', dest='index',
-     help='Name of the index page (symlinked to <GALLERY-1> (%default)')
-    parser.add_option('--gallery', default='gallery-%d.html', dest='gallery',
-     help='Name of the gallery page, must include a %%d. (%default)')
-    parser.add_option('--up-link', default=None, dest='up_link',
-     help='Text for link to gallery parent')
-    parser.add_option('--pagedir', default='./pages', dest='pagedir',
-     help='Relative path from gallery page to page directory (%default)')
-    parser.add_option('--thumbdir', default='./thumbs', dest='thumbdir',
-     help='Relative path from gallery page to thumbnail directory (%default)')
-    parser.add_option('-r', '--rows', default=4, type='int', dest='cols',
-     help='Rows of thumbnails per page (%default)')
-    parser.add_option('-c', '--cols', default=10, type='int', dest='rows',
-     help='Columns of thumbnails per page (%default)')
-    parser.add_option('-H', '--height', default=100, type='int', dest='height',
-     help='Maximum thumbnail height in pixels (%default)')
-    parser.add_option('-W', '--width', default=300, type='int', dest='width',
-     help='Maximum thumbnail width in pixels (%default)')
-    parser.add_option('--force', default=False, dest='force',
-     help='Regenerate existing gallery files', action='store_true')
-    parser.add_option('-v', '--verbose', default=0, dest='verbose',
-     help='Increment verbosity', action='count')
-    options,args = parser.parse_args()
-
-    logging.basicConfig(level=level)
-    levels =
-    level = [
-        logging.WARNING,
-        logging.INFO,
-        logging.DEBUG][options.verbose]
-
-    for picdir in args:
-        kwargs = {}
-        for attr in ['index', 'gallery', 'up_link', 'pagedir', 'thumbdir',
-                     'cols', 'rows', 'height', 'width', 'force']:
-            kwargs[attr] = getattr(options, attr)
-        make_gallery(picdir, **kwargs)
+    LOG.debug('entering __main__ loop')
+
+    LOG.debug('importing cgi')
+    import cgi
+    LOG.debug('importing cgitb')
+    import cgitb
+
+    LOG.debug('parsing arguments')
+
+    url = None
+    page = 0
+    if '--url' in sys.argv:
+        i = sys.argv.index('--url')
+        try:
+            url = sys.argv[i+1]
+        except IndexError:
+            pass
+        if '--page' in sys.argv:
+            i = sys.argv.index('--page')
+            page = int(sys.argv[i+1])
+    else:
+        cgitb.enable()
+        #cgitb.enable(display=0, logdir="/tmp/")
+        data = cgi.FieldStorage()
+        if 'p' in data:
+            p = data['p']
+            if isinstance(p, list):
+                p = p[0]
+            url = p.value
+        if 'pp' in data:
+            try:
+                page = int(data['pp'].value) - 1
+            except ValueError:
+                pass
+
+    s = CGIGalleryServer(
+        base_path='/var/www/localhost/htdocs/gallery/',
+        base_url='/gallery/')
+    shared = '/var/www/localhost/htdocs/shared/'
+    s.header = [open(os.path.join(shared, 'header.shtml'), 'r').read()]
+    s.footer = [open(os.path.join(shared, 'footer.shtml'), 'r').read()]
+    s.serve(url=url, page=page)