3 # Copyright (C) 2010-2012 W. Trevor King <wking@drexel.edu>
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.
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.
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
20 CGI gallery server for a picture directory organized along::
25 | |-- another_picture.jpg
29 | |-- captioned_picture.jpg
30 | |-- captioned_picture.jpg.txt
35 pics$ gallery.py some_directory another_directory
37 Note that you can store a caption for ``<PICTURE>`` as plain text in
40 See RFC 3875 for more details on the the Common Gateway Interface.
42 This script can also be run as a Simple Common Gateway Interface
43 (SCGI) with the ``--scgi`` option.
46 import logging as _logging
47 import logging.handlers as _logging_handlers
50 import os.path as _os_path
51 import random as _random
53 import subprocess as _subprocess
59 IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.tif', '.tiff', '.png', '.gif']
60 VIDEO_EXTENSIONS = ['.mov', '.mp4', '.ogv']
61 RESPONSES = { # httplib takes half a second to load
66 LOG = _logging.getLogger('gallery.py')
67 LOG.addHandler(_logging.StreamHandler())
68 #LOG.addHandler(_logging_handlers.SysLogHandler())
69 LOG.handlers[0].setFormatter(
70 _logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
71 LOG.setLevel(_logging.DEBUG)
72 #LOG.setLevel(_logging.WARNING)
75 class CommandError(Exception):
76 def __init__(self, command, status, stdout=None, stderr=None):
77 strerror = ['Command failed (%d):\n %s\n' % (status, stderr),
78 'while executing\n %s' % str(command)]
79 Exception.__init__(self, '\n'.join(strerror))
80 self.command = command
86 class ProcessingComplete(Exception):
90 def invoke(args, stdin=None, stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
91 expect=(0,), cwd=None, encoding=None):
93 expect should be a tuple of allowed exit codes. cwd should be
94 the directory from which the command will be executed. When
95 unicode_output == True, convert stdout and stdin strings to
96 unicode before returing them.
100 LOG.debug('{}$ {}'.format(cwd, ' '.join(args)))
102 q = _subprocess.Popen(args, stdin=_subprocess.PIPE, stdout=stdout,
103 stderr=stderr, cwd=cwd)
105 raise CommandError(args, status=e.args[0], stderr=e)
106 stdout,stderr = q.communicate(input=stdin)
108 LOG.debug('{:d}\n{}{}'.format(status, stdout, stderr))
109 if status not in expect:
110 raise CommandError(args, status, stdout, stderr)
111 return status, stdout, stderr
113 def is_picture(filename):
114 for extension in IMAGE_EXTENSIONS:
115 if filename.lower().endswith(extension):
119 def image_base(filename):
120 parts = filename.rsplit('.', 1)
121 assert len(parts) == 2, parts
125 class CGIGalleryServer (object):
126 def __init__(self, base_path='/var/www/localhost/htdocs/gallery/',
127 base_url='/cgi-bin/gallery.py',
128 cache_path='/tmp/gallery-cache/'):
129 self._base_path = _os_path.abspath(base_path)
130 self._base_url = base_url
131 self._cache_path = cache_path
132 self._url_regexp = _re.compile('^[a-z0-9._/-]*$')
138 def _http_header(self, mime='text/html', status=200):
139 msg = RESPONSES[status]
140 header = ['Status: {:d} {}'.format(status, msg)]
141 if mime.startswith('text/'):
142 charset = '; charset=UTF-8'
145 header.append('Content-type: {}{}'.format(mime, charset))
146 return '\n'.join(header)
148 def _response(self, header=None, content='<h1>It works!</h1>',
151 header = self._http_header()
154 stream.write(content)
155 raise ProcessingComplete()
157 def _response_stream(self, header=None, content=None, stream=None,
159 LOG.debug('streaming response')
161 header = self._http_header()
164 stream.flush() # flush headers
166 chunk = content.read(chunk_size)
170 raise ProcessingComplete()
172 def _error(self, status=404, content=None, stream=None):
173 header = self._http_header(status=status)
175 content = RESPONSES[status]
176 self._response(header=header, content=content, stream=stream)
178 def validate_url(self, url, stream=None):
179 LOG.debug('validating {}'.format(repr(url)))
182 elif (not self._url_regexp.match(url) or
183 url.startswith('/') or
186 LOG.error('invalid url')
187 self._error(404, stream=stream)
188 path = _os_path.join(self._base_path, url)
189 if _os_path.exists(path) and not _os_path.isdir(path):
190 LOG.error('nonexistent directory')
191 self._error(404, stream=stream)
193 def serve(self, url=None, page=0, stream=None):
194 LOG.info('serving url {} (page {})'.format(url, page))
197 self.index(stream=stream)
198 elif url.endswith('random'):
200 url=url, stream=stream, max_width=500, max_height=500)
201 elif self.is_cached(url=url):
202 self.cached(url=url, stream=stream)
203 elif url.endswith('.png'):
204 self._thumb(url=url, stream=stream)
206 self.validate_url(url=url, stream=stream)
207 self.page(url=url, page=page, stream=stream)
208 LOG.error('unexpected url type')
209 self._error(404, stream=stream)
210 except ProcessingComplete:
213 def relative_url(self, url, stream=None):
216 if not url.startswith(self._base_url):
217 LOG.error('cannot convert {} to a relative URL of {}'.format(
218 url, self._base_url))
219 return self._error(404, stream=stream)
220 if url == self._base_url:
222 return url[len(self._base_url):]
224 def _url(self, path):
225 relpath = _os_path.relpath(
226 _os_path.join(self._base_path, path), self._base_path)
229 elif path.endswith('/'):
231 return '{}{}'.format(self._base_url, relpath)
233 def _label(self, path):
234 dirname,base = _os_path.split(path)
235 if not base: # directory path ending with '/'
236 dirname,base = _os_path.split(dirname)
237 return base.replace('_', ' ').title()
239 def _link(self, path, text=None):
241 text = self._label(path)
242 return '<a href="{}">{}</a>'.format(self._url(path), text)
244 def _subdirs(self, path):
246 order = [d.strip() for d in
247 open(_os_path.join(path, '_order')).readlines()]
250 dirs = sorted(_os.listdir(path))
256 for d in start + dirs:
257 dirpath = _os_path.join(path, d)
258 if _os_path.isdir(dirpath):
261 def _images(self, path):
262 for p in sorted(_os.listdir(path)):
263 if p.startswith('.') or p.endswith('~'):
265 picture_path = _os_path.join(path, p)
266 if is_picture(picture_path):
269 def index(self, stream=None):
270 LOG.debug('index page')
271 return self._directory(self._base_path, stream=stream)
273 def _thumb(self, image, max_width=None, max_height=None, stream=None):
274 if not _os_path.exists(self._cache_path):
275 _os.makedirs(self._cache_path)
276 dirname,filename = _os_path.split(image)
277 reldir = _os_path.relpath(dirname, self._base_path)
278 cache_dir = _os_path.join(self._cache_path, reldir)
279 if not _os_path.isdir(cache_dir):
280 _os.makedirs(cache_dir)
281 extension = '-{:d}-{:d}.png'.format(max_width, max_height)
282 thumb_filename = image_base(filename)+extension
283 thumb_url = _os_path.join(dirname, thumb_filename)
284 thumb_path = _os_path.join(cache_dir, thumb_filename)
285 image_path = _os_path.join(self._base_path, image)
286 if not _os_path.isfile(image_path):
287 LOG.error('image path for thumbnail does not exist')
288 return self._error(404, stream=stream)
289 if (not _os_path.isfile(thumb_path)
290 or _os_path.getmtime(image_path) > _os_path.getmtime(thumb_path)):
291 invoke(['convert', '-format', 'png', '-strip', '-quality', '95',
293 '-thumbnail', '{:d}x{:d}'.format(max_width, max_height),
297 def _mp4(self, video, stream=None):
298 if not video.endswith('.mov'):
299 LOG.error("can't translate {} to MPEGv4".format(video))
300 dirname,filename = _os_path.split(video)
301 mp4_filename = image_base(filename) + '.mp4'
302 reldir = _os_path.relpath(dirname, self._base_path)
303 cache_dir = _os_path.join(self._cache_path, reldir)
304 if not _os_path.isdir(cache_dir):
305 _os.makedirs(cache_dir)
306 mp4_url = _os_path.join(dirname, mp4_filename)
307 mp4_path = _os_path.join(cache_dir, mp4_filename)
308 if not _os_path.isfile(video):
309 LOG.error('source video path does not exist')
310 return self._error(404, stream=stream)
311 if (not _os_path.isfile(mp4_path)
312 or _os_path.getmtime(video) > _os_path.getmtime(mp4_path)):
313 arg = ['ffmpeg', '-i', video, '-acodec', 'libfaac', '-aq', '200',
314 '-ac', '1', '-s', '640x480', '-vcodec', 'libx264',
315 '-preset', 'slower', '-vpre', 'ipod640', '-b', '800k',
316 '-bt', '800k', '-aspect', '640:480', '-threads', '0']
320 return self._url(mp4_url)
322 def _ogv(self, video, stream=None):
323 if not video.endswith('.mov'):
324 LOG.error("can't translate {} to Ogg Video".format(video))
325 dirname,filename = _os_path.split(video)
326 ogv_filename = image_base(filename) + '.ogv'
327 reldir = _os_path.relpath(dirname, self._base_path)
328 cache_dir = _os_path.join(self._cache_path, reldir)
329 if not _os_path.isdir(cache_dir):
330 _os.makedirs(cache_dir)
331 ogv_url = _os_path.join(dirname, ogv_filename)
332 ogv_path = _os_path.join(cache_dir, ogv_filename)
333 if not _os_path.isfile(video):
334 LOG.error('source video path does not exist')
335 return self._error(404, stream=stream)
336 if (not _os_path.isfile(ogv_path)
337 or _os_path.getmtime(video) > _os_path.getmtime(ogv_path)):
338 arg = ['ffmpeg2theora', '--optimize']
340 arg.extend(['--output', ogv_path, video])
342 return self._url(ogv_url)
344 def _get_image_caption(self, path):
345 caption_path = path + '.txt'
347 return open(caption_path, 'r').read()
351 def _get_image_video(self, path, fallback=None, stream=None):
352 base_path = image_base(path)
353 for extension in VIDEO_EXTENSIONS:
354 video_path = base_path + extension
355 if _os_path.isfile(video_path):
357 video_path, fallback=fallback, stream=stream)
360 def _captioned_video(self, path, href=None, stream=None):
361 img = self._image(path, max_width=640, max_height=480, stream=stream)
362 caption = self._get_image_caption(path)
363 video = self._get_image_video(path, fallback=[img], stream=stream)
366 content.extend(video)
368 content.append('<p>{}</p>'.format(
369 self._link(path=href, text='gallery page')))
371 content.append(self._link(path=href, text=img))
375 content.append('<p>{}</p>'.format(caption))
378 def _video(self, video, fallback=None, stream=None, **kwargs):
381 '<p>Your browser does not support the <video> tag, try',
382 'downloading the video and playing it in an external player.',
385 fallback = [' '+line for line in fallback]
386 ogv = self._ogv(video, stream=stream)
387 mp4 = self._mp4(video, stream=stream)
390 (' <video preloads="none" controls="controls" '
391 'width="640" height="480">'),
392 ' <source src="{}"'.format(mp4),
393 (''' type='video/mp4; '''
394 '''codecs="avc1.42E01E, mp4a.40.2"' />'''),
395 ' <source src="{}"'.format(ogv),
396 ''' type='video/ogg; codecs="theora,vorbis"' />''',
401 ' <a href="{}">Ogg/Theora/Vorbis</a> or'.format(ogv),
402 (' <a href="{}">Mpeg4/H.264(ConstrainedBaselineProfile)/AAC</a>.'
407 def _image(self, image, **kwargs):
409 image = self._thumb(image, **kwargs)
410 return '<img src="{}" />'.format(self._url(image))
412 def _image_page(self, image):
413 return image_base(image) + '/'
415 def random(self, url=None, stream=None, **kwargs):
416 LOG.debug('random image')
417 if url.endswith('/random'):
418 base_dir = _os_path.join(
419 self._base_path, url[:(-len('/random'))])
420 elif url == 'random':
421 base_dir = self._base_path
423 self._error(404, stream=stream)
425 for dirpath,dirnames,filenames in _os.walk(base_dir):
426 for filename in filenames:
427 if is_picture(filename):
428 images.append(_os_path.join(dirpath, filename))
430 self._response(content='<p>no images to choose from</p>',
432 image = _random.choice(images)
433 LOG.debug('selected random image {}'.format(image))
434 page = self._image_page(image)
435 content = self._captioned_video(path=image, href=page, stream=stream)
436 self._response(content='\n'.join(content), stream=stream)
438 def is_cached(self, url):
439 for extension in ['.png', '.mp4', '.ogv']:
440 if url.endswith(extension):
444 def cached(self, url, stream=None):
445 LOG.debug('retrieving cached item')
446 if url.endswith('.png'):
448 elif url.endswith('.ogv'):
450 elif url.endswith('.mp4'):
453 raise NotImplementedError()
454 header = self._http_header(mime=mime)
455 cache_path = _os_path.join(self._cache_path, url)
457 content = open(cache_path, 'rb')
459 LOG.error('invalid url')
461 self._error(404, stream=stream)
462 if mime in ['video/ogg']:
463 self._response_stream(
464 header=header, content=content, stream=stream)
465 content = content.read()
466 self._response(header=header, content=content, stream=stream)
468 def page(self, url, page=0, stream=None):
469 LOG.debug('HTML page')
470 if not url.endswith('/'):
471 LOG.error('HTML page URLs must end with a slash')
472 self._error(404, stream=stream)
473 abspath = _os_path.join(self._base_path, url)
474 if _os_path.isdir(abspath):
475 self._directory(path=abspath, page=page, stream=stream)
476 for extension in IMAGE_EXTENSIONS:
477 file_path = abspath[:-1] + extension
478 if _os_path.isfile(file_path):
479 self._page(path=file_path, stream=stream)
480 LOG.debug('unknown HTML page')
481 self._error(404, stream=stream)
483 def _directory_header(self, path):
484 relpath = _os_path.relpath(path, self._base_path)
488 dirname,base = _os_path.split(dirname)
490 crumbs.insert(0, base)
492 links = [None] * len(crumbs)
493 for i,c in enumerate(crumbs):
494 if i < len(crumbs)-1:
496 links[i] = self._link(self._base_path, 'Gallery')
498 relpath = '/'.join(crumbs[1:i+1]) + '/'
499 fullpath = _os_path.join(self._base_path, relpath)
500 links[i] = self._link(path=fullpath)
505 links[i] = self._label(crumbs[i])
506 content = ['<h1>{}</h1>'.format(' '.join(links))]
509 def _directory_page_navigation(self, path, page, pages):
512 prev_page = path + '?pp={:d}'.format((page - 1) % pages + 1)
513 next_page = path + '?pp={:d}'.format((page + 1) % pages + 1)
515 '<div style="text-align: center;">',
517 self._link(prev_page, 'previous'),
518 '({:d} of {:d})'.format(page+1, pages),
519 self._link(next_page, 'next'),
524 def _directory_subdirs(self, path):
526 dirs = list(self._subdirs(path))
528 content.append('<ul>')
530 content.append(' <li>{}</li>'.format(self._link(d+'/')))
531 content.append('</ul>')
534 def _directory_images(self, path, images, stream=None):
535 content = ['<table style="margin-left: auto; margin-right: auto;">']
538 page = self._image_page(image)
540 image, max_width=300, max_height=300, stream=stream)
541 link = self._link(page, img)
543 content.append(' <tr>')
545 ' <td style="text-align: center;">',
550 if column == self._columns:
551 content.append(' </tr>')
555 content.append(' </tr>')
556 content.append('</table>')
559 def _directory(self, path, page=0, stream=None):
560 LOG.debug('directory page')
561 images = list(self._images(path))
562 images_per_page = self._rows * self._columns
563 pages = int(_math.ceil(float(len(images)) / images_per_page)) or 1
564 if page < 0 or page >= pages:
566 'page out of bounds for this gallery 0 <= {:d} < {:d}'.format(
568 self._error(404, stream=stream)
569 first_image = images_per_page * page
570 images = images[first_image:first_image+images_per_page]
572 content.extend(self.header)
573 content.extend(self._directory_header(path))
574 nav = self._directory_page_navigation(path, page=page, pages=pages)
576 content.extend(self._directory_subdirs(path))
577 content.extend(self._directory_images(
578 path, images=images, stream=stream))
580 content.extend(self.footer)
581 self._response(content='\n'.join(content), stream=stream)
583 def _page(self, path, stream=None):
584 LOG.debug('image page')
585 gallery = _os_path.dirname(path)
586 images = list(self._images(gallery))
587 images_per_page = self._rows * self._columns
588 i = images.index(path)
589 page = i / images_per_page
590 gallery_page = '{}/?pp={:d}'.format(gallery, page + 1)
591 prev_page = self._image_page(images[i - 1])
592 next_page = self._image_page(images[(i + 1) % len(images)])
594 content.extend(self.header)
596 '<div style="text-align: center;">',
598 self._link(prev_page, 'previous'),
599 self._link(gallery_page, 'all'),
600 self._link(next_page, 'next'),
603 content.extend(self._captioned_video(path, stream=stream))
604 content.append('</div>')
605 content.extend(self.footer)
606 self._response(content='\n'.join(content), stream=stream)
609 def serve_cgi(server):
617 #cgitb.enable(display=0, logdir="/tmp/")
618 data = cgi.FieldStorage()
621 if isinstance(p, list):
626 page = int(data['pp'].value) - 1
629 server.serve(url=url, page=page, stream=sys.stdout)
631 def serve_scgi(server, host='localhost', port=4000):
633 import scgi.scgi_server
636 class GalleryHandler(scgi.scgi_server.SCGIHandler):
637 def produce(self, env, bodysize, input, output):
638 #LOG.info(HTTP_USER_AGENT REQUEST_METHOD REMOTE_ADDR REQUEST_URI
639 url = env.get('DOCUMENT_URI', None)
641 data = urlparse.parse_qs(env.get('QUERY_STRING', ''))
644 if isinstance(pp, list):
651 url = server.relative_url(url=url, stream=output)
652 except ProcessingComplete:
655 server.serve(url=url, page=page, stream=output)
657 s = scgi.scgi_server.SCGIServer(
658 handler_class=GalleryHandler, host=host, port=port)
659 LOG.info('serving SCGI on {}:{}'.format(host, port))
663 if __name__ == '__main__':
664 import argparse as _argparse
666 parser = _argparse.ArgumentParser(
667 description=__doc__, version=__version__,
668 formatter_class=_argparse.RawDescriptionHelpFormatter)
670 '--scgi', default=False, action='store_const', const=True,
671 help='Run as a SCGI server (vs. serving a single CGI call)')
673 '--port', default=4000, type=int,
674 help='Port to listen to (if runing as a SCGI server)')
676 '--base-path', default='.',
677 help='Path to the root gallery source')
679 '--base-url', default='/',
680 help='URL for the root gallery source')
682 '--shared-path', default=None,
683 help=('Optional path to the shared directory containing '
684 '`header.shtml` and `footer.shtml`'))
686 '--cache-path', default='/tmp/gallery-cache',
687 help='Path to the thumbnail and movie cache directory')
689 args = parser.parse_args()
691 s = CGIGalleryServer(
692 base_path=args.base_path, base_url=args.base_url,
693 cache_path=args.cache_path)
695 shared = args.shared_path
696 s.header = [open(_os_path.join(shared, 'header.shtml'), 'r').read()]
697 s.footer = [open(_os_path.join(shared, 'footer.shtml'), 'r').read()]
700 serve_scgi(server=s, port=args.port)