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
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
49 import mimetypes as _mimetypes
51 import os.path as _os_path
52 import random as _random
54 import subprocess as _subprocess
55 import xml.sax.saxutils as _xml_sax_saxutils
61 IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.tif', '.tiff', '.png', '.gif']
62 VIDEO_EXTENSIONS = ['.mov', '.mp4', '.ogv']
63 STREAMING_TYPES = ['video/ogg']
64 RESPONSES = { # httplib takes half a second to load
69 LOG = _logging.getLogger('gallery.py')
70 LOG.addHandler(_logging.StreamHandler())
71 #LOG.addHandler(_logging_handlers.SysLogHandler())
72 LOG.handlers[0].setFormatter(
73 _logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
74 LOG.setLevel(_logging.DEBUG)
75 #LOG.setLevel(_logging.WARNING)
78 class CommandError(Exception):
79 def __init__(self, command, status, stdout=None, stderr=None):
80 strerror = ['Command failed (%d):\n %s\n' % (status, stderr),
81 'while executing\n %s' % str(command)]
82 super(CommandError, self).__init__('\n'.join(strerror))
83 self.command = command
89 class HTTPError(Exception):
90 def __init__(self, status, message=None, content=None):
92 message = RESPONSES[status]
93 super(HTTPError, self).__init__('{} {}'.format(status, message))
95 self.message = message
96 self.content = content
99 class ProcessingComplete(Exception):
103 def invoke(args, stdin=None, stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
104 expect=(0,), cwd=None, encoding=None):
106 expect should be a tuple of allowed exit codes. cwd should be
107 the directory from which the command will be executed. When
108 unicode_output == True, convert stdout and stdin strings to
109 unicode before returing them.
113 LOG.debug('{}$ {}'.format(cwd, ' '.join(args)))
115 q = _subprocess.Popen(args, stdin=_subprocess.PIPE, stdout=stdout,
116 stderr=stderr, cwd=cwd)
118 raise CommandError(args, status=e.args[0], stderr=e)
119 stdout,stderr = q.communicate(input=stdin)
121 LOG.debug('{:d}\n{}{}'.format(status, stdout, stderr))
122 if status not in expect:
123 raise CommandError(args, status, stdout, stderr)
124 return status, stdout, stderr
126 def is_image(filename):
127 for extension in IMAGE_EXTENSIONS:
128 if filename.lower().endswith(extension):
132 def is_video(filename):
133 for extension in IMAGE_EXTENSIONS:
134 if filename.lower().endswith(extension):
138 def image_base(filename):
139 parts = filename.rsplit('.', 1)
140 assert len(parts) == 2, parts
144 class CGIGalleryServer (object):
145 def __init__(self, base_path='.',
147 cache_path='/tmp/gallery-cache/',
148 serve_originals=True):
149 self._base_path = _os_path.abspath(base_path)
150 self._base_url = base_url
151 self._cache_path = cache_path
152 self._serve_originals = serve_originals
153 self._url_regexp = _re.compile('^[a-zA-Z0-9._/-]*$')
159 def _http_header(self, mime='text/html', status=200):
160 msg = RESPONSES[status]
161 header = ['Status: {:d} {}'.format(status, msg)]
162 if mime.startswith('text/'):
163 charset = '; charset=UTF-8'
166 header.append('Content-type: {}{}'.format(mime, charset))
167 return '\n'.join(header)
169 def _response(self, header=None, content='<h1>It works!</h1>',
172 header = self._http_header()
175 stream.write(content)
176 raise ProcessingComplete()
178 def _response_stream(self, header=None, content=None, stream=None,
180 LOG.debug('streaming response')
182 header = self._http_header()
185 stream.flush() # flush headers
187 chunk = content.read(chunk_size)
191 raise ProcessingComplete()
193 def _error(self, status=404, content=None, stream=None):
194 header = self._http_header(status=status)
196 content = RESPONSES[status]
197 self._response(header=header, content=content, stream=stream)
199 def validate_url(self, url, exists=True, directory=False):
200 LOG.debug('validating {} (exists={}, directory={})'.format(
201 repr(url), exists, directory))
204 elif (not self._url_regexp.match(url) or
205 url.startswith('/') or
208 LOG.error('invalid url')
211 path = _os_path.join(self._base_path, url)
213 if not _os_path.isdir(path):
214 LOG.error('nonexistent directory')
217 if not _os_path.isfile(path):
218 raise HTTPError(404, 'nonexistent file')
220 def serve(self, url=None, page=0, stream=None):
221 LOG.info('serving url {} (page {})'.format(url, page))
225 self.index(stream=stream)
226 elif url.endswith('random'):
228 url=url, stream=stream, max_width=500, max_height=500)
229 elif self.is_cacheable(url=url):
230 self.validate_url(url=url, exists=False)
231 self.cached(url=url, stream=stream)
233 self.validate_url(url=url, exists=False, directory=True)
234 self.page(url=url, page=page, stream=stream)
235 raise HTTPError(404, 'unexpected URL type')
236 except HTTPError as e:
238 self._error(e.status, content=e.content, stream=stream)
239 except ProcessingComplete:
242 def relative_url(self, url):
245 if not url.startswith(self._base_url):
246 message = 'cannot convert {} to a relative URL of {}'.format(
248 raise HTTPError(404, message)
249 if url == self._base_url:
251 return url[len(self._base_url):]
253 def _url(self, path):
254 relpath = _os_path.relpath(
255 _os_path.join(self._base_path, path), self._base_path)
258 elif path.endswith('/'):
260 return '{}{}'.format(self._base_url, relpath)
262 def _label(self, path):
263 dirname,base = _os_path.split(path)
264 if not base: # directory path ending with '/'
265 dirname,base = _os_path.split(dirname)
266 return base.replace('_', ' ').title()
268 def _link(self, path, text=None):
270 text = self._label(path)
271 return '<a href="{}">{}</a>'.format(self._url(path), text)
273 def _subdirs(self, path):
275 order = [d.strip() for d in
276 open(_os_path.join(path, '_order')).readlines()]
279 dirs = sorted(_os.listdir(path))
285 for d in start + dirs:
286 dirpath = _os_path.join(path, d)
287 if _os_path.isdir(dirpath):
290 def _images(self, path):
291 for p in sorted(_os.listdir(path)):
292 if p.startswith('.') or p.endswith('~'):
294 picture_path = _os_path.join(path, p)
295 if is_image(picture_path):
298 def index(self, stream=None):
299 LOG.debug('index page')
300 return self._directory(self._base_path, stream=stream)
302 def _original_url(self, url):
303 """Reverse thumbnail URL mapping
305 Returns (original_url, generating_callback, callback_kwargs).
307 base,extension = _os_path.splitext(url)
308 if extension in ['.png']:
310 root,width,height = base.rsplit('-', 2)
312 raise HTTPError(404, 'missing width/height in {}'.format(base))
316 except ValueError as e:
317 raise HTTPError(404, 'invalid width/height: {}'.format(e))
322 'max_height': height},
324 elif extension in VIDEO_EXTENSIONS:
327 getattr(self, '_{}'.format(extension), None),
330 raise HTTPError(404, 'no original URL for {}'.format(url))
332 def _thumb(self, image, max_width=None, max_height=None):
333 if not _os_path.exists(self._cache_path):
334 _os.makedirs(self._cache_path)
335 dirname,filename = _os_path.split(image)
336 reldir = _os_path.relpath(dirname, self._base_path)
337 cache_dir = _os_path.join(self._cache_path, reldir)
338 if not _os_path.isdir(cache_dir):
339 _os.makedirs(cache_dir)
340 extension = '-{:d}-{:d}.png'.format(max_width, max_height)
341 thumb_filename = image_base(filename)+extension
342 thumb_url = _os_path.join(dirname, thumb_filename)
343 thumb_path = _os_path.join(cache_dir, thumb_filename)
344 image_path = _os_path.join(self._base_path, image)
345 if not _os_path.isfile(image_path):
346 raise HTTPError(404, 'image path for thumbnail does not exist')
347 if (not _os_path.isfile(thumb_path)
348 or _os_path.getmtime(image_path) > _os_path.getmtime(thumb_path)):
349 invoke(['convert', '-format', 'png', '-strip', '-quality', '95',
351 '-thumbnail', '{:d}x{:d}'.format(max_width, max_height),
353 return (thumb_path, self._url(thumb_url))
355 def _mp4(self, video, *args):
356 if not video.endswith('.mov'):
357 raise HTTPError(404, "can't translate {} to MPEGv4".format(video))
358 dirname,filename = _os_path.split(video)
359 mp4_filename = image_base(filename) + '.mp4'
360 reldir = _os_path.relpath(dirname, self._base_path)
361 cache_dir = _os_path.join(self._cache_path, reldir)
362 if not _os_path.isdir(cache_dir):
363 _os.makedirs(cache_dir)
364 mp4_url = _os_path.join(dirname, mp4_filename)
365 mp4_path = _os_path.join(cache_dir, mp4_filename)
366 if not _os_path.isfile(video):
367 raise HTTPError(404, 'source video path does not exist')
368 if (not _os_path.isfile(mp4_path)
369 or _os_path.getmtime(video) > _os_path.getmtime(mp4_path)):
370 arg = ['ffmpeg', '-i', video, '-acodec', 'libfaac', '-aq', '200',
371 '-ac', '1', '-s', '640x480', '-vcodec', 'libx264',
372 '-preset', 'slower', '-vpre', 'ipod640', '-b', '800k',
373 '-bt', '800k', '-aspect', '640:480', '-threads', '0']
377 return (mp4_path, self._url(mp4_url))
379 def _ogv(self, video, *args):
380 if not video.endswith('.mov'):
381 LOG.error("can't translate {} to Ogg Video".format(video))
382 dirname,filename = _os_path.split(video)
383 ogv_filename = image_base(filename) + '.ogv'
384 reldir = _os_path.relpath(dirname, self._base_path)
385 cache_dir = _os_path.join(self._cache_path, reldir)
386 if not _os_path.isdir(cache_dir):
387 _os.makedirs(cache_dir)
388 ogv_url = _os_path.join(dirname, ogv_filename)
389 ogv_path = _os_path.join(cache_dir, ogv_filename)
390 if not _os_path.isfile(video):
391 LOG.error('source video path does not exist')
392 if (not _os_path.isfile(ogv_path)
393 or _os_path.getmtime(video) > _os_path.getmtime(ogv_path)):
394 arg = ['ffmpeg2theora', '--optimize']
396 arg.extend(['--output', ogv_path, video])
398 return (ogv_path, self._url(ogv_url))
400 def _get_image_caption(self, path):
401 caption_path = path + '.txt'
403 return open(caption_path, 'r').read()
407 def _get_image_video(self, path, fallback=None):
408 base_path = image_base(path)
409 for extension in VIDEO_EXTENSIONS:
410 video_path = base_path + extension
411 if _os_path.isfile(video_path):
412 return self._video(video_path, fallback=fallback)
415 def _captioned_video(self, path, href=None):
416 img = self._image(path, max_width=640, max_height=480)
417 caption = self._get_image_caption(path)
418 video = self._get_image_video(path, fallback=[img])
421 content.extend(video)
423 content.append('<p>{}</p>'.format(
424 self._link(path=href, text='gallery page')))
426 content.append(self._link(path=href, text=img))
430 caption = _xml_sax_saxutils.escape(caption)
431 content.append('<p>{}</p>'.format(caption))
434 def _video(self, video, fallback=None, **kwargs):
437 '<p>Your browser does not support the <video> tag, try',
438 'downloading the video and playing it in an external player.',
441 fallback = [' '+line for line in fallback]
442 ogv_path,ogv_url = self._ogv(video)
443 mp4_path,mp4_url = self._mp4(video)
446 (' <video preloads="none" controls="controls" '
447 'width="640" height="480">'),
448 ' <source src="{}"'.format(mp4_url),
449 (''' type='video/mp4; '''
450 '''codecs="avc1.42E01E, mp4a.40.2"' />'''),
451 ' <source src="{}"'.format(ogv_url),
452 ''' type='video/ogg; codecs="theora,vorbis"' />''',
457 ' <a href="{}">Ogg/Theora/Vorbis</a> or'.format(ogv_url),
458 (' <a href="{}">Mpeg4/H.264(ConstrainedBaselineProfile)/AAC</a>.'
463 def _image(self, image, **kwargs):
465 image_path,image_url = self._thumb(image, **kwargs)
468 sections = ['<img src="{}"'.format(image_url)]
469 caption = self._get_image_caption(path=image)
471 caption = _xml_sax_saxutils.quoteattr(
472 caption.replace('\n', ' ').strip())
474 'title={}'.format(caption),
475 'alt={}'.format(caption),
477 sections.append('/>')
478 return ' '.join(sections)
480 def _image_page(self, image):
481 return image_base(image) + '/'
483 def random(self, url=None, stream=None, **kwargs):
484 LOG.debug('random image')
485 if url.endswith('/random'):
486 url = url[:(-len('/random'))]
487 self.validate_url(url=url, directory=True, stream=stream)
488 base_dir = _os_path.join(self._base_path, url)
489 elif url == 'random':
490 base_dir = self._base_path
494 for dirpath,dirnames,filenames in _os.walk(base_dir):
495 for filename in filenames:
496 if is_image(filename):
497 images.append(_os_path.join(dirpath, filename))
499 self._response(content='<p>no images to choose from</p>',
501 image = _random.choice(images)
502 LOG.debug('selected random image {}'.format(image))
503 page = self._image_page(image)
504 content = self._captioned_video(path=image, href=page)
505 self._response(content='\n'.join(content), stream=stream)
507 def is_cacheable(self, url):
508 return is_image(url) or is_video(url)
510 def cached(self, url, stream=None):
511 LOG.debug('retrieving possibly cached item')
512 mime = _mimetypes.guess_type(url)[0]
514 raise HTTPError(404, 'unknown mime type for {}'.format(url))
515 cache_path = _os_path.join(self._cache_path, url)
516 original_path = _os_path.join(self._base_path, url)
518 if _os_path.isfile(cache_path):
519 LOG.debug('return cached item {}'.format(cache_path))
521 elif self._serve_originals and _os_path.isfile(original_path):
522 LOG.debug('return original item {}'.format(original_path))
525 LOG.debug('possibly create cached item {}'.format(cache_path))
526 original_url,callback,kwargs = self._original_url(url)
527 original_path = _os_path.join(self._base_path, original_url)
528 if callback and _os_path.isfile(original_path):
529 path,cache_url = callback(original_path, **kwargs)
533 content = open(path, 'rb')
536 raise HTTPError(404, 'item not found {}'.format(url))
537 header = self._http_header(mime=mime)
538 if mime in STREAMING_TYPES:
539 self._response_stream(
540 header=header, content=content, stream=stream)
541 content = content.read()
542 self._response(header=header, content=content, stream=stream)
544 def page(self, url, page=0, stream=None):
545 LOG.debug('HTML page {} {}'.format(url, page))
546 if not url.endswith('/'):
547 raise HTTPError(404, 'HTML page URLs must end with a slash')
548 abspath = _os_path.join(self._base_path, url)
549 if _os_path.isdir(abspath):
550 self._directory(path=abspath, page=page, stream=stream)
551 for extension in IMAGE_EXTENSIONS:
552 file_path = abspath[:-1] + extension
553 if _os_path.isfile(file_path):
554 self._page(path=file_path, stream=stream)
555 raise HTTPError(404, 'unknown HTML page {}'.format(url))
557 def _directory_header(self, path):
558 relpath = _os_path.relpath(path, self._base_path)
562 dirname,base = _os_path.split(dirname)
564 crumbs.insert(0, base)
566 links = [None] * len(crumbs)
567 for i,c in enumerate(crumbs):
568 if i < len(crumbs)-1:
570 links[i] = self._link(self._base_path, 'Gallery')
572 relpath = '/'.join(crumbs[1:i+1]) + '/'
573 fullpath = _os_path.join(self._base_path, relpath)
574 links[i] = self._link(path=fullpath)
579 links[i] = self._label(crumbs[i])
580 content = ['<h1>{}</h1>'.format(' '.join(links))]
583 def _directory_page_navigation(self, path, page, pages):
586 prev_page = path + '?pp={:d}'.format((page - 1) % pages + 1)
587 next_page = path + '?pp={:d}'.format((page + 1) % pages + 1)
589 '<div style="text-align: center;">',
591 self._link(prev_page, 'previous'),
592 '({:d} of {:d})'.format(page+1, pages),
593 self._link(next_page, 'next'),
598 def _directory_subdirs(self, path):
600 dirs = list(self._subdirs(path))
602 content.append('<ul>')
604 content.append(' <li>{}</li>'.format(self._link(d+'/')))
605 content.append('</ul>')
608 def _directory_images(self, path, images):
609 content = ['<table style="margin-left: auto; margin-right: auto;">']
612 page = self._image_page(image)
613 img = self._image(image, max_width=300, max_height=300)
614 link = self._link(page, img)
616 content.append(' <tr>')
618 ' <td style="text-align: center;">',
623 if column == self._columns:
624 content.append(' </tr>')
628 content.append(' </tr>')
629 content.append('</table>')
632 def _directory(self, path, page=0, stream=None):
633 LOG.debug('directory page {} {}'.format(path, page))
634 images = list(self._images(path))
635 images_per_page = self._rows * self._columns
636 pages = int(_math.ceil(float(len(images)) / images_per_page)) or 1
637 if page < 0 or page >= pages:
640 'page out of bounds for this gallery 0 <= {:d} < {:d}'.format(
642 first_image = images_per_page * page
643 images = images[first_image:first_image+images_per_page]
645 content.extend(self.header)
646 content.extend(self._directory_header(path))
647 nav = self._directory_page_navigation(path, page=page, pages=pages)
649 content.extend(self._directory_subdirs(path))
650 content.extend(self._directory_images(path, images=images))
652 content.extend(self.footer)
653 self._response(content='\n'.join(content), stream=stream)
655 def _page(self, path, stream=None):
656 LOG.debug('image page {}'.format(path))
657 gallery = _os_path.dirname(path)
658 images = list(self._images(gallery))
659 images_per_page = self._rows * self._columns
660 i = images.index(path)
661 page = i / images_per_page
662 gallery_page = '{}/?pp={:d}'.format(gallery, page + 1)
663 prev_page = self._image_page(images[i - 1])
664 next_page = self._image_page(images[(i + 1) % len(images)])
666 content.extend(self.header)
668 '<div style="text-align: center;">',
670 self._link(prev_page, 'previous'),
671 self._link(gallery_page, 'all'),
672 self._link(next_page, 'next'),
675 content.extend(self._captioned_video(path))
676 content.append('</div>')
677 content.extend(self.footer)
678 self._response(content='\n'.join(content), stream=stream)
681 def serve_cgi(server):
689 #cgitb.enable(display=0, logdir="/tmp/")
690 data = cgi.FieldStorage()
693 if isinstance(p, list):
698 page = int(data['pp'].value) - 1
701 server.serve(url=url, page=page, stream=sys.stdout)
703 def serve_scgi(server, host='localhost', port=4000):
705 import scgi.scgi_server
708 class GalleryHandler(scgi.scgi_server.SCGIHandler):
709 def produce(self, env, bodysize, input, output):
710 #LOG.info(HTTP_USER_AGENT REQUEST_METHOD REMOTE_ADDR REQUEST_URI
711 url = env.get('DOCUMENT_URI', None)
713 data = urlparse.parse_qs(env.get('QUERY_STRING', ''))
716 if isinstance(pp, list):
724 url = server.relative_url(url=url)
725 except HTTPError as e:
727 server._error(e.status, content=e.content, stream=stream)
728 except ProcessingComplete:
731 server.serve(url=url, page=page, stream=output)
733 s = scgi.scgi_server.SCGIServer(
734 handler_class=GalleryHandler, host=host, port=port)
735 LOG.info('serving SCGI on {}:{}'.format(host, port))
739 if __name__ == '__main__':
740 import argparse as _argparse
742 parser = _argparse.ArgumentParser(
743 description=__doc__, version=__version__,
744 formatter_class=_argparse.RawDescriptionHelpFormatter)
746 '--scgi', default=False, action='store_const', const=True,
747 help='Run as a SCGI server (vs. serving a single CGI call)')
749 '--port', default=4000, type=int,
750 help='Port to listen to (if runing as a SCGI server)')
752 '--base-path', default='.',
753 help='Path to the root gallery source')
755 '--base-url', default='/',
756 help='URL for the root gallery source')
758 '--shared-path', default=None,
759 help=('Optional path to the shared directory containing '
760 '`header.shtml` and `footer.shtml`'))
762 '--cache-path', default='/tmp/gallery-cache',
763 help='Path to the thumbnail and movie cache directory')
765 args = parser.parse_args()
767 s = CGIGalleryServer(
768 base_path=args.base_path, base_url=args.base_url,
769 cache_path=args.cache_path)
771 shared = args.shared_path
772 s.header = [open(_os_path.join(shared, 'header.shtml'), 'r').read()]
773 s.footer = [open(_os_path.join(shared, 'footer.shtml'), 'r').read()]
776 serve_scgi(server=s, port=args.port)