plugins: videos: photos: add videos plugin

This change adds a new plugin that mirrors photos functionality, but for
videos (mp4 and others).

NOTE: this plugin requires ffmepg installed on the host machine

  $ sudo apt-get install -y ffmpeg
diff --git a/Makefile b/Makefile
index ead5917..daa6b60 100644
--- a/Makefile
+++ b/Makefile
@@ -7,6 +7,7 @@
 OUTPUTDIR=$(BASEDIR)/output
 OUTPUTDIR_THEME=$(OUTPUTDIR)/theme
 OUTPUTDIR_PHOTOS=$(OUTPUTDIR)/photos
+OUTPUTDIR_VIDEOS=$(OUTPUTDIR)/videos
 CONFFILE=$(BASEDIR)/pelicanconf.py
 PUBLISHCONF=$(BASEDIR)/publishconf.py
 
@@ -35,8 +36,11 @@
 endif
 INSTALLFLAGS := $(INSTALLFLAGS) -rXaA
 INSTALLDIR ?=
+# Copy the whole directory theme
 INSTALLDIR_THEME ?=
+# Copy contents only
 INSTALLDIR_PHOTOS ?=
+INSTALLDIR_VIDEOS ?=
 
 DEBUG ?= 0
 ifeq ($(DEBUG), 1)
@@ -56,6 +60,8 @@
 	@echo '   make clean-html                     remove all html files and folders  '
 	@echo '   make clean-theme                    remove output/theme                '
 	@echo '   make clean-photos                   remove output/photos               '
+	@echo '   make clean-videos                   remove output/videos               '
+	@echo '   make distclean                      remove output and __pycache__      '
 	@echo '   make clean                          remove output folder               '
 	@echo '   make regenerate                     regenerate files upon modification '
 	@echo '   make publish                        generate using production settings '
@@ -73,6 +79,7 @@
 	@echo '   make install-html                   copy *.html, html folders locally  '
 	@echo '   make install-theme                  copy output/theme locally          '
 	@echo '   make install-photos                 copy output/photos locally         '
+	@echo '   make install-videos                 copy output/videos locally         '
 	@echo '   make install                        copy output/* locally              '
 	@echo '                                                                          '
 	@echo 'Set the DEBUG variable to 1 to enable debugging, e.g. make DEBUG=1 html   '
@@ -92,9 +99,15 @@
 clean-photos:
 	[ ! -d "$(OUTPUTDIR_PHOTOS)" ] || rm -rf $(OUTPUTDIR_PHOTOS)
 
+clean-videos:
+	[ ! -d "$(OUTPUTDIR_VIDEOS)" ] || rm -rf $(OUTPUTDIR_VIDEOS)
+
 clean:
 	[ ! -d "$(OUTPUTDIR)" ] || rm -rf $(OUTPUTDIR)
 
+distclean: clean
+	find $(BASEDIR) -type d -name "__pycache__" -exec rm -rf '{}' '+'
+
 regenerate:
 	$(PELICAN) -r $(INPUTDIR) -o $(OUTPUTDIR) -s $(CONFFILE) $(PELICANOPTS)
 
@@ -151,18 +164,27 @@
 
 install-html:
 	[ -d "$(OUTPUTDIR)" ] && [ -d "$(INSTALLDIR)" ] && \
-	$(SUDO) rsync $(INSTALLFLAGS) --exclude=$(notdir $(OUTPUTDIR_THEME)) \
-	--exclude=$(notdir $(OUTPUTDIR_PHOTOS)) $(OUTPUTDIR)/* $(INSTALLDIR)
+	$(SUDO) rsync $(INSTALLFLAGS) \
+	--exclude=$(notdir $(OUTPUTDIR_THEME)) \
+	--exclude=$(notdir $(OUTPUTDIR_PHOTOS)) \
+	--exclude=$(notdir $(OUTPUTDIR_VIDEOS)) \
+	$(OUTPUTDIR)/* $(INSTALLDIR)
 
 install-theme:
 	[ -d "$(OUTPUTDIR_THEME)" ] && [ -d "$(INSTALLDIR_THEME)" ] && \
 	$(SUDO) rsync $(INSTALLFLAGS) $(OUTPUTDIR_THEME) $(INSTALLDIR_THEME)/
 
 install-photos:
-	[ -d "$(OUTPUTDIR_PHOTOS)" ] && [ -d "$(INSTALLDIR_PHOTOS)" ] && \
-	$(SUDO) rsync $(INSTALLFLAGS) $(OUTPUTDIR_PHOTOS) $(INSTALLDIR_PHOTOS)/
+	[ ! -d "$(OUTPUTDIR_PHOTOS)" ] && { echo "Nothing to do for: install-photos"; \
+	exit 0; } || [ -d "$(INSTALLDIR_PHOTOS)" ] && \
+	$(SUDO) rsync $(INSTALLFLAGS) $(OUTPUTDIR_PHOTOS)/ $(INSTALLDIR_PHOTOS)
 
-install: install-html install-theme install-photos
+install-videos:
+	[ ! -d "$(OUTPUTDIR_VIDEOS)" ] && { echo "Nothing to do for: install-videos"; \
+	exit 0; } || [ -d "$(INSTALLDIR_VIDEOS)" ] && \
+	$(SUDO) rsync $(INSTALLFLAGS) $(OUTPUTDIR_VIDEOS)/ $(INSTALLDIR_VIDEOS)
+
+install: install-html install-theme install-photos install-videos
 
 
 .PHONY: html help clean clean-html clean-theme clean-photos regenerate serve serve-global devserver stopserver publish ssh_upload rsync_upload dropbox_upload ftp_upload s3_upload cf_upload github install install-html install-theme install-photos
diff --git a/pelicanconf.py b/pelicanconf.py
index f7e0f58..d100903 100644
--- a/pelicanconf.py
+++ b/pelicanconf.py
@@ -75,6 +75,7 @@
 PLUGINS = [
     'assets',
     'photos',
+    'videos',
 ]
 
 # Derive categories from the folder name
@@ -207,7 +208,7 @@
 # {url} placeholder in PAGINATION_PATTERNS.
 DAY_ARCHIVE_URL = ''
 
-# Gallery plugin
+# Photos plugin
 PHOTO_LIBRARY = os.getenv('PELICAN_PHOTO_LIBRARY')
 PHOTO_EXCLUDE = os.getenv('PELICAN_PHOTO_EXCLUDE')
 PHOTO_EXCLUDEALL = os.getenv('PELICAN_PHOTO_EXCLUDEALL')
@@ -215,3 +216,10 @@
 PHOTO_ARTICLE = (2000, 1333, 100)
 PHOTO_THUMB = (300, 200, 100)
 PHOTO_SQUARE_THUMB = False
+
+# Videos plugin
+VIDEO_LIBRARY = os.getenv('PELICAN_VIDEO_LIBRARY')
+VIDEO_EXCLUDE = os.getenv('PELICAN_VIDEO_EXCLUDE')
+VIDEO_EXCLUDEALL = os.getenv('PELICAN_VIDEO_EXCLUDEALL')
+VIDEO_GALLERY = (720, 400, 100)
+VIDEO_ARTICLE = (720, 400, 100)
diff --git a/plugins/photos/photos.py b/plugins/photos/photos.py
index 551bdf6..2c6966a 100644
--- a/plugins/photos/photos.py
+++ b/plugins/photos/photos.py
@@ -123,7 +123,9 @@
     if resized not in DEFAULT_CONFIG['queue_resize']:
         DEFAULT_CONFIG['queue_resize'][resized] = (orig, spec)
     elif DEFAULT_CONFIG['queue_resize'][resized] != (orig, spec):
-        logger.error('photos: resize conflict for {}, {}-{} is not {}-{}'.format(resized, DEFAULT_CONFIG['queue_resize'][resized][0], DEFAULT_CONFIG['queue_resize'][resized][1], orig, spec))
+        error_msg = 'photos: resize conflict for {}, {}-{} is not {}-{}'
+        logger.error(error_msg.format(resized, DEFAULT_CONFIG['queue_resize'][resized][0],
+                                      DEFAULT_CONFIG['queue_resize'][resized][1], orig, spec))
 
 
 def isalpha(img):
@@ -314,7 +316,7 @@
         basename = os.path.basename(abs_path)
 
         if basename in generator.settings['PHOTO_EXCLUDE']:
-            logger.warning('photos: Skip gallery: {}'.format(basename))
+            logger.warning('photos: skip gallery: {}'.format(basename))
             continue
 
         orig, spec = what
@@ -523,15 +525,15 @@
                     generator.settings['PHOTO_THUMB'])
 
             content.photo_gallery.append((title, content_gallery))
-            logger.debug('Gallery Data: '.format(pprint.pformat(content.photo_gallery)))
+            logger.debug('Gallery Data: {}'.format(pprint.pformat(content.photo_gallery)))
             DEFAULT_CONFIG['created_galleries']['gallery'] = content_gallery
         else:
             logger.error('photos: Gallery does not exist: {} at {}'.format(gallery['location'], dir_gallery))
 
 
 def detect_gallery(generator, content):
-    if 'gallery' in content.metadata:
-        gallery = content.metadata.get('gallery')
+    if 'photo-gallery' in content.metadata:
+        gallery = content.metadata.get('photo-gallery')
         if gallery.startswith('{photo}') or gallery.startswith('{filename}'):
             process_gallery(generator, content, gallery)
         elif gallery:
diff --git a/plugins/videos/__init__.py b/plugins/videos/__init__.py
new file mode 100644
index 0000000..08503e9
--- /dev/null
+++ b/plugins/videos/__init__.py
@@ -0,0 +1 @@
+from .videos import *
diff --git a/plugins/videos/api.txt b/plugins/videos/api.txt
new file mode 100644
index 0000000..8db8ae3
--- /dev/null
+++ b/plugins/videos/api.txt
@@ -0,0 +1,210 @@
+## Container formats
+--------------------
+
+class converter.formats.AviFormat
+
+    Avi container format, often used vith DivX video.
+
+class converter.formats.BaseFormat
+
+    Base format class.
+
+    Supported formats are: ogg, avi, mkv, webm, flv, mov, mp4, mpeg
+
+class converter.formats.FlvFormat
+
+    Flash Video container format.
+
+class converter.formats.MkvFormat
+
+    Matroska format, often used with H.264 video.
+
+class converter.formats.MovFormat
+
+    Mov container format, used mostly with H.264 video content, often for mobile platforms.
+
+class converter.formats.Mp3Format
+
+    Mp3 container, used audio-only mp3 files
+
+class converter.formats.Mp4Format
+
+    Mp4 container format, the default Format for H.264 video content.
+
+class converter.formats.MpegFormat
+
+    MPEG(TS) container, used mainly for MPEG 1/2 video codecs.
+
+class converter.formats.OggFormat
+
+    Ogg container format, mostly used with Vorbis and Theora.
+
+class converter.formats.WebmFormat
+
+    WebM is Google’s variant of Matroska containing only VP8 for video and Vorbis for audio content.
+
+
+## Audio and video codecs
+-------------------------
+
+class converter.avcodecs.AacCodec
+
+    AAC audio codec.
+
+class converter.avcodecs.Ac3Codec
+
+    AC3 audio codec.
+
+class converter.avcodecs.AudioCodec
+
+    Base audio codec class handles general audio options. Possible parameters are:
+
+            codec (string) - audio codec name
+            channels (integer) - number of audio channels
+            bitrate (integer) - stream bitrate
+            samplerate (integer) - sample rate (frequency)
+
+    Supported audio codecs are: null (no audio), copy (copy from original), vorbis, aac, mp3, mp2
+
+class converter.avcodecs.AudioCopyCodec
+
+    Copy audio stream directly from the source.
+
+class converter.avcodecs.AudioNullCodec
+
+    Null audio codec (no audio).
+
+class converter.avcodecs.BaseCodec
+
+    Base audio/video codec class.
+
+class converter.avcodecs.DVBSub
+
+    DVB subtitles.
+
+class converter.avcodecs.DVDSub
+
+    DVD subtitles.
+
+class converter.avcodecs.DivxCodec
+
+    DivX video codec.
+
+class converter.avcodecs.DtsCodec
+
+    DTS audio codec.
+
+class converter.avcodecs.FdkAacCodec
+
+    AAC audio codec.
+
+class converter.avcodecs.FlacCodec
+
+    FLAC audio codec.
+
+class converter.avcodecs.FlvCodec
+
+    Flash Video codec.
+
+class converter.avcodecs.H263Codec
+
+    H.263 video codec.
+
+class converter.avcodecs.H264Codec
+
+    H.264/AVC video codec. @see http://ffmpeg.org/trac/ffmpeg/wiki/x264EncodingGuide
+
+class converter.avcodecs.MOVTextCodec
+
+    mov_text subtitle codec.
+
+class converter.avcodecs.Mp2Codec
+
+    MP2 (MPEG layer 2) audio codec.
+
+class converter.avcodecs.Mp3Codec
+
+    MP3 (MPEG layer 3) audio codec.
+
+class converter.avcodecs.Mpeg1Codec
+
+    MPEG-1 video codec.
+
+class converter.avcodecs.Mpeg2Codec
+
+    MPEG-2 video codec.
+
+class converter.avcodecs.MpegCodec
+
+    Base MPEG video codec.
+
+class converter.avcodecs.SSA
+
+    SSA (SubStation Alpha) subtitle.
+
+class converter.avcodecs.SubRip
+
+    SubRip subtitle.
+
+class converter.avcodecs.SubtitleCodec
+
+    Base subtitle codec class handles general subtitle options. Possible parameters are:
+
+            codec (string) - subtitle codec name (mov_text, subrib, ssa only supported currently)
+            language (string) - language of subtitle stream (3 char code)
+            forced (int) - force subtitles (1 true, 0 false)
+            default (int) - default subtitles (1 true, 0 false)
+
+    Supported subtitle codecs are: null (no subtitle), mov_text
+
+class converter.avcodecs.SubtitleCopyCodec
+
+    Copy subtitle stream directly from the source.
+
+class converter.avcodecs.SubtitleNullCodec
+
+    Null video codec (no video).
+
+class converter.avcodecs.TheoraCodec
+
+    Theora video codec. @see http://ffmpeg.org/trac/ffmpeg/wiki/TheoraVorbisEncodingGuide
+
+class converter.avcodecs.VideoCodec
+
+    Base video codec class handles general video options. Possible parameters are:
+
+            codec (string) - video codec name
+            bitrate (string) - stream bitrate
+            fps (integer) - frames per second
+            width (integer) - video width
+            height (integer) - video height
+
+            mode (string) - aspect preserval mode; one of:
+                    stretch (default) - don’t preserve aspect
+                    crop - crop extra w/h
+                    pad - pad with black bars
+
+            src_width (int) - source width
+            src_height (int) - source height
+
+    Aspect preserval mode is only used if both source and both destination sizes are specified. If source dimensions are not specified, aspect settings are ignored.
+
+    If source dimensions are specified, and only one of the destination dimensions is specified, the other one is calculated to preserve the aspect ratio.
+
+    Supported video codecs are: null (no video), copy (copy directly from the source), Theora, H.264/AVC, DivX, VP8, H.263, Flv, MPEG-1, MPEG-2.
+
+class converter.avcodecs.VideoCopyCodec
+
+    Copy video stream directly from the source.
+
+class converter.avcodecs.VideoNullCodec
+
+    Null video codec (no video).
+
+class converter.avcodecs.VorbisCodec
+
+    Vorbis audio codec. @see http://ffmpeg.org/trac/ffmpeg/wiki/TheoraVorbisEncodingGuide
+
+class converter.avcodecs.Vp8Codec
+
+    Google VP8 video codec.
diff --git a/plugins/videos/avcodecs.py b/plugins/videos/avcodecs.py
new file mode 100644
index 0000000..1c853f9
--- /dev/null
+++ b/plugins/videos/avcodecs.py
@@ -0,0 +1,637 @@
+#!/usr/bin/env python
+
+
+class BaseCodec(object):
+    """
+    Base audio/video codec class.
+    """
+
+    encoder_options = {}
+    codec_name = None
+    ffmpeg_codec_name = None
+
+    def parse_options(self, opt):
+        if 'codec' not in opt or opt['codec'] != self.codec_name:
+            raise ValueError('invalid codec name')
+        return None
+
+    def _codec_specific_parse_options(self, safe):
+        return safe
+
+    def _codec_specific_produce_ffmpeg_list(self, safe):
+        return []
+
+    def safe_options(self, opts):
+        safe = {}
+
+        # Only copy options that are expected and of correct type
+        # (and do typecasting on them)
+        for k, v in opts.items():
+            if k in self.encoder_options:
+                typ = self.encoder_options[k]
+                try:
+                    safe[k] = typ(v)
+                except:
+                    pass
+
+        return safe
+
+
+class AudioCodec(BaseCodec):
+    """
+    Base audio codec class handles general audio options. Possible
+    parameters are:
+      * codec (string) - audio codec name
+      * channels (integer) - number of audio channels
+      * bitrate (integer) - stream bitrate
+      * samplerate (integer) - sample rate (frequency)
+
+    Supported audio codecs are: null (no audio), copy (copy from
+    original), vorbis, aac, mp3, mp2
+    """
+
+    encoder_options = {
+        'codec': str,
+        'channels': int,
+        'bitrate': int,
+        'samplerate': int
+    }
+
+    def parse_options(self, opt):
+        super(AudioCodec, self).parse_options(opt)
+
+        safe = self.safe_options(opt)
+
+        if 'channels' in safe:
+            c = safe['channels']
+            if c < 1 or c > 12:
+                del safe['channels']
+
+        if 'bitrate' in safe:
+            br = safe['bitrate']
+            if br < 8 or br > 512:
+                del safe['bitrate']
+
+        if 'samplerate' in safe:
+            f = safe['samplerate']
+            if f < 1000 or f > 50000:
+                del safe['samplerate']
+
+        safe = self._codec_specific_parse_options(safe)
+
+        optlist = ['-acodec', self.ffmpeg_codec_name]
+        if 'channels' in safe:
+            optlist.extend(['-ac', str(safe['channels'])])
+        if 'bitrate' in safe:
+            optlist.extend(['-ab', str(safe['bitrate']) + 'k'])
+        if 'samplerate' in safe:
+            optlist.extend(['-ar', str(safe['samplerate'])])
+
+        optlist.extend(self._codec_specific_produce_ffmpeg_list(safe))
+        return optlist
+
+
+class SubtitleCodec(BaseCodec):
+    """
+    Base subtitle codec class handles general subtitle options. Possible
+    parameters are:
+      * codec (string) - subtitle codec name (mov_text, subrib, ssa only supported currently)
+      * language (string) - language of subtitle stream (3 char code)
+      * forced (int) - force subtitles (1 true, 0 false)
+      * default (int) - default subtitles (1 true, 0 false)
+
+    Supported subtitle codecs are: null (no subtitle), mov_text
+    """
+
+    encoder_options = {
+        'codec': str,
+        'language': str,
+        'forced': int,
+        'default': int
+    }
+
+    def parse_options(self, opt):
+        super(SubtitleCodec, self).parse_options(opt)
+        safe = self.safe_options(opt)
+
+        if 'forced' in safe:
+            f = safe['forced']
+            if f < 0 or f > 1:
+                del safe['forced']
+
+        if 'default' in safe:
+            d = safe['default']
+            if d < 0 or d > 1:
+                del safe['default']
+
+        if 'language' in safe:
+            l = safe['language']
+            if len(l) > 3:
+                del safe['language']
+
+        safe = self._codec_specific_parse_options(safe)
+
+        optlist = ['-scodec', self.ffmpeg_codec_name]
+
+        optlist.extend(self._codec_specific_produce_ffmpeg_list(safe))
+        return optlist
+
+
+class VideoCodec(BaseCodec):
+    """
+    Base video codec class handles general video options. Possible
+    parameters are:
+      * codec (string) - video codec name
+      * bitrate (string) - stream bitrate
+      * fps (integer) - frames per second
+      * width (integer) - video width
+      * height (integer) - video height
+      * mode (string) - aspect preserval mode; one of:
+            * stretch (default) - don't preserve aspect
+            * crop - crop extra w/h
+            * pad - pad with black bars
+      * src_width (int) - source width
+      * src_height (int) - source height
+
+    Aspect preserval mode is only used if both source
+    and both destination sizes are specified. If source
+    dimensions are not specified, aspect settings are ignored.
+
+    If source dimensions are specified, and only one
+    of the destination dimensions is specified, the other one
+    is calculated to preserve the aspect ratio.
+
+    Supported video codecs are: null (no video), copy (copy directly
+    from the source), Theora, H.264/AVC, DivX, VP8, H.263, Flv,
+    MPEG-1, MPEG-2.
+    """
+
+    encoder_options = {
+        'codec': str,
+        'bitrate': int,
+        'fps': int,
+        'width': int,
+        'height': int,
+        'mode': str,
+        'src_width': int,
+        'src_height': int,
+    }
+
+    def _aspect_corrections(self, sw, sh, w, h, mode):
+        # If we don't have source info, we don't try to calculate
+        # aspect corrections
+        if not sw or not sh:
+            return w, h, None
+
+        # Original aspect ratio
+        aspect = (1.0 * sw) / (1.0 * sh)
+
+        # If we have only one dimension, we can easily calculate
+        # the other to match the source aspect ratio
+        if not w and not h:
+            return w, h, None
+        elif w and not h:
+            h = int((1.0 * w) / aspect)
+            return w, h, None
+        elif h and not w:
+            w = int(aspect * h)
+            return w, h, None
+
+        # If source and target dimensions are actually the same aspect
+        # ratio, we've got nothing to do
+        if int(aspect * h) == w:
+            return w, h, None
+
+        if mode == 'stretch':
+            return w, h, None
+
+        target_aspect = (1.0 * w) / (1.0 * h)
+
+        if mode == 'crop':
+            # source is taller, need to crop top/bottom
+            if target_aspect > aspect:  # target is taller
+                h0 = int(w / aspect)
+                assert h0 > h, (sw, sh, w, h)
+                dh = (h0 - h) / 2
+                return w, h0, 'crop=%d:%d:0:%d' % (w, h, dh)
+            else:  # source is wider, need to crop left/right
+                w0 = int(h * aspect)
+                assert w0 > w, (sw, sh, w, h)
+                dw = (w0 - w) / 2
+                return w0, h, 'crop=%d:%d:%d:0' % (w, h, dw)
+
+        if mode == 'pad':
+            # target is taller, need to pad top/bottom
+            if target_aspect < aspect:
+                h1 = int(w / aspect)
+                assert h1 < h, (sw, sh, w, h)
+                dh = (h - h1) / 2
+                return w, h1, 'pad=%d:%d:0:%d' % (w, h, dh)  # FIXED
+            else:  # target is wider, need to pad left/right
+                w1 = int(h * aspect)
+                assert w1 < w, (sw, sh, w, h)
+                dw = (w - w1) / 2
+                return w1, h, 'pad=%d:%d:%d:0' % (w, h, dw)  # FIXED
+
+        assert False, mode
+
+    def parse_options(self, opt):
+        super(VideoCodec, self).parse_options(opt)
+
+        safe = self.safe_options(opt)
+
+        if 'fps' in safe:
+            f = safe['fps']
+            if f < 1 or f > 120:
+                del safe['fps']
+
+        if 'bitrate' in safe:
+            br = safe['bitrate']
+            if br < 16 or br > 15000:
+                del safe['bitrate']
+
+        w = None
+        h = None
+
+        if 'width' in safe:
+            w = safe['width']
+            if w < 16 or w > 4000:
+                w = None
+
+        if 'height' in safe:
+            h = safe['height']
+            if h < 16 or h > 3000:
+                h = None
+
+        sw = None
+        sh = None
+
+        if 'src_width' in safe and 'src_height' in safe:
+            sw = safe['src_width']
+            sh = safe['src_height']
+            if not sw or not sh:
+                sw = None
+                sh = None
+
+        mode = 'stretch'
+        if 'mode' in safe:
+            if safe['mode'] in ['stretch', 'crop', 'pad']:
+                mode = safe['mode']
+
+        ow, oh = w, h  # FIXED
+        w, h, filters = self._aspect_corrections(sw, sh, w, h, mode)
+
+        safe['width'] = w
+        safe['height'] = h
+        safe['aspect_filters'] = filters
+
+        if w and h:
+            safe['aspect'] = '%d:%d' % (w, h)
+
+        safe = self._codec_specific_parse_options(safe)
+
+        w = safe['width']
+        h = safe['height']
+        filters = safe['aspect_filters']
+
+        optlist = ['-vcodec', self.ffmpeg_codec_name]
+        if 'fps' in safe:
+            optlist.extend(['-r', str(safe['fps'])])
+        if 'bitrate' in safe:
+            optlist.extend(['-vb', str(safe['bitrate']) + 'k'])  # FIXED
+        if w and h:
+            optlist.extend(['-s', '%dx%d' % (w, h)])
+
+            if ow and oh:
+                optlist.extend(['-aspect', '%d:%d' % (ow, oh)])
+
+        if filters:
+            optlist.extend(['-vf', filters])
+
+        optlist.extend(self._codec_specific_produce_ffmpeg_list(safe))
+        return optlist
+
+
+class AudioNullCodec(BaseCodec):
+    """
+    Null audio codec (no audio).
+    """
+    codec_name = None
+
+    def parse_options(self, opt):
+        return ['-an']
+
+
+class VideoNullCodec(BaseCodec):
+    """
+    Null video codec (no video).
+    """
+
+    codec_name = None
+
+    def parse_options(self, opt):
+        return ['-vn']
+
+
+class SubtitleNullCodec(BaseCodec):
+    """
+    Null video codec (no video).
+    """
+
+    codec_name = None
+
+    def parse_options(self, opt):
+        return ['-sn']
+
+
+class AudioCopyCodec(BaseCodec):
+    """
+    Copy audio stream directly from the source.
+    """
+    codec_name = 'copy'
+
+    def parse_options(self, opt):
+        return ['-acodec', 'copy']
+
+
+class VideoCopyCodec(BaseCodec):
+    """
+    Copy video stream directly from the source.
+    """
+    codec_name = 'copy'
+
+    def parse_options(self, opt):
+        return ['-vcodec', 'copy']
+
+
+class SubtitleCopyCodec(BaseCodec):
+    """
+    Copy subtitle stream directly from the source.
+    """
+    codec_name = 'copy'
+
+    def parse_options(self, opt):
+        return ['-scodec', 'copy']
+
+# Audio Codecs
+class VorbisCodec(AudioCodec):
+    """
+    Vorbis audio codec.
+    @see http://ffmpeg.org/trac/ffmpeg/wiki/TheoraVorbisEncodingGuide
+    """
+    codec_name = 'vorbis'
+    ffmpeg_codec_name = 'libvorbis'
+    encoder_options = AudioCodec.encoder_options.copy()
+    encoder_options.update({
+        'quality': int,  # audio quality. Range is 0-10(highest quality)
+        # 3-6 is a good range to try. Default is 3
+    })
+
+    def _codec_specific_produce_ffmpeg_list(self, safe):
+        optlist = []
+        if 'quality' in safe:
+            optlist.extend(['-qscale:a', safe['quality']])
+        return optlist
+
+
+class AacCodec(AudioCodec):
+    """
+    AAC audio codec.
+    """
+    codec_name = 'aac'
+    ffmpeg_codec_name = 'aac'
+    aac_experimental_enable = ['-strict', 'experimental']
+
+    def _codec_specific_produce_ffmpeg_list(self, safe):
+        return self.aac_experimental_enable
+
+
+class FdkAacCodec(AudioCodec):
+    """
+    AAC audio codec.
+    """
+    codec_name = 'libfdk_aac'
+    ffmpeg_codec_name = 'libfdk_aac'
+
+
+class Ac3Codec(AudioCodec):
+    """
+    AC3 audio codec.
+    """
+    codec_name = 'ac3'
+    ffmpeg_codec_name = 'ac3'
+
+
+class FlacCodec(AudioCodec):
+    """
+    FLAC audio codec.
+    """
+    codec_name = 'flac'
+    ffmpeg_codec_name = 'flac'
+
+
+class DtsCodec(AudioCodec):
+    """
+    DTS audio codec.
+    """
+    codec_name = 'dts'
+    ffmpeg_codec_name = 'dts'
+
+
+class Mp3Codec(AudioCodec):
+    """
+    MP3 (MPEG layer 3) audio codec.
+    """
+    codec_name = 'mp3'
+    ffmpeg_codec_name = 'libmp3lame'
+
+
+class Mp2Codec(AudioCodec):
+    """
+    MP2 (MPEG layer 2) audio codec.
+    """
+    codec_name = 'mp2'
+    ffmpeg_codec_name = 'mp2'
+
+
+# Video Codecs
+class TheoraCodec(VideoCodec):
+    """
+    Theora video codec.
+    @see http://ffmpeg.org/trac/ffmpeg/wiki/TheoraVorbisEncodingGuide
+    """
+    codec_name = 'theora'
+    ffmpeg_codec_name = 'libtheora'
+    encoder_options = VideoCodec.encoder_options.copy()
+    encoder_options.update({
+        'quality': int,  # audio quality. Range is 0-10(highest quality)
+        # 5-7 is a good range to try (default is 200k bitrate)
+    })
+
+    def _codec_specific_produce_ffmpeg_list(self, safe):
+        optlist = []
+        if 'quality' in safe:
+            optlist.extend(['-qscale:v', safe['quality']])
+        return optlist
+
+
+class H264Codec(VideoCodec):
+    """
+    H.264/AVC video codec.
+    @see http://ffmpeg.org/trac/ffmpeg/wiki/x264EncodingGuide
+    """
+    codec_name = 'h264'
+    ffmpeg_codec_name = 'libx264'
+    encoder_options = VideoCodec.encoder_options.copy()
+    encoder_options.update({
+        'preset': str,  # common presets are ultrafast, superfast, veryfast,
+        # faster, fast, medium(default), slow, slower, veryslow
+        'quality': int,  # constant rate factor, range:0(lossless)-51(worst)
+        # default:23, recommended: 18-28
+        # http://mewiki.project357.com/wiki/X264_Settings#profile
+        'profile': str,  # default: not-set, for valid values see above link
+        'tune': str,  # default: not-set, for valid values see above link
+    })
+
+    def _codec_specific_produce_ffmpeg_list(self, safe):
+        optlist = []
+        if 'preset' in safe:
+            optlist.extend(['-preset', safe['preset']])
+        if 'quality' in safe:
+            optlist.extend(['-crf', safe['quality']])
+        if 'profile' in safe:
+            optlist.extend(['-profile', safe['profile']])
+        if 'tune' in safe:
+            optlist.extend(['-tune', safe['tune']])
+        return optlist
+
+
+class DivxCodec(VideoCodec):
+    """
+    DivX video codec.
+    """
+    codec_name = 'divx'
+    ffmpeg_codec_name = 'mpeg4'
+
+
+class Vp8Codec(VideoCodec):
+    """
+    Google VP8 video codec.
+    """
+    codec_name = 'vp8'
+    ffmpeg_codec_name = 'libvpx'
+
+
+class H263Codec(VideoCodec):
+    """
+    H.263 video codec.
+    """
+    codec_name = 'h263'
+    ffmpeg_codec_name = 'h263'
+
+
+class FlvCodec(VideoCodec):
+    """
+    Flash Video codec.
+    """
+    codec_name = 'flv'
+    ffmpeg_codec_name = 'flv'
+
+
+class MpegCodec(VideoCodec):
+    """
+    Base MPEG video codec.
+    """
+    # Workaround for a bug in ffmpeg in which aspect ratio
+    # is not correctly preserved, so we have to set it
+    # again in vf; take care to put it *before* crop/pad, so
+    # it uses the same adjusted dimensions as the codec itself
+    # (pad/crop will adjust it further if neccessary)
+    def _codec_specific_parse_options(self, safe):
+        w = safe['width']
+        h = safe['height']
+
+        if w and h:
+            filters = safe['aspect_filters']
+            tmp = 'aspect=%d:%d' % (w, h)
+
+            if filters is None:
+                safe['aspect_filters'] = tmp
+            else:
+                safe['aspect_filters'] = tmp + ',' + filters
+
+        return safe
+
+
+class Mpeg1Codec(MpegCodec):
+    """
+    MPEG-1 video codec.
+    """
+    codec_name = 'mpeg1'
+    ffmpeg_codec_name = 'mpeg1video'
+
+
+class Mpeg2Codec(MpegCodec):
+    """
+    MPEG-2 video codec.
+    """
+    codec_name = 'mpeg2'
+    ffmpeg_codec_name = 'mpeg2video'
+
+
+# Subtitle Codecs
+class MOVTextCodec(SubtitleCodec):
+    """
+    mov_text subtitle codec.
+    """
+    codec_name = 'mov_text'
+    ffmpeg_codec_name = 'mov_text'
+
+
+class SSA(SubtitleCodec):
+    """
+    SSA (SubStation Alpha) subtitle.
+    """
+    codec_name = 'ass'
+    ffmpeg_codec_name = 'ass'
+
+
+class SubRip(SubtitleCodec):
+    """
+    SubRip subtitle.
+    """
+    codec_name = 'subrip'
+    ffmpeg_codec_name = 'subrip'
+
+
+class DVBSub(SubtitleCodec):
+    """
+    DVB subtitles.
+    """
+    codec_name = 'dvbsub'
+    ffmpeg_codec_name = 'dvbsub'
+
+
+class DVDSub(SubtitleCodec):
+    """
+    DVD subtitles.
+    """
+    codec_name = 'dvdsub'
+    ffmpeg_codec_name = 'dvdsub'
+
+
+audio_codec_list = [
+    AudioNullCodec, AudioCopyCodec, VorbisCodec, AacCodec, Mp3Codec, Mp2Codec,
+    FdkAacCodec, Ac3Codec, DtsCodec, FlacCodec
+]
+
+video_codec_list = [
+    VideoNullCodec, VideoCopyCodec, TheoraCodec, H264Codec,
+    DivxCodec, Vp8Codec, H263Codec, FlvCodec, Mpeg1Codec,
+    Mpeg2Codec
+]
+
+subtitle_codec_list = [
+    SubtitleNullCodec, SubtitleCopyCodec, MOVTextCodec, SSA, SubRip, DVDSub,
+    DVBSub
+]
diff --git a/plugins/videos/ffmpeg.py b/plugins/videos/ffmpeg.py
new file mode 100644
index 0000000..6b14133
--- /dev/null
+++ b/plugins/videos/ffmpeg.py
@@ -0,0 +1,543 @@
+#!/usr/bin/env python
+
+import os.path
+import os
+import re
+import signal
+from subprocess import Popen, PIPE
+import logging
+import locale
+
+logger = logging.getLogger(__name__)
+
+console_encoding = locale.getdefaultlocale()[1] or 'UTF-8'
+
+
+class FFMpegError(Exception):
+    pass
+
+
+class FFMpegConvertError(Exception):
+    def __init__(self, message, cmd, output, details=None, pid=0):
+        """
+        @param    message: Error message.
+        @type     message: C{str}
+
+        @param    cmd: Full command string used to spawn ffmpeg.
+        @type     cmd: C{str}
+
+        @param    output: Full stdout output from the ffmpeg command.
+        @type     output: C{str}
+
+        @param    details: Optional error details.
+        @type     details: C{str}
+        """
+        super(FFMpegConvertError, self).__init__(message)
+
+        self.cmd = cmd
+        self.output = output
+        self.details = details
+        self.pid = pid
+
+    def __repr__(self):
+        error = self.details if self.details else self.message
+        return ('<FFMpegConvertError error="%s", pid=%s, cmd="%s">' %
+                (error, self.pid, self.cmd))
+
+    def __str__(self):
+        return self.__repr__()
+
+
+class MediaFormatInfo(object):
+    """
+    Describes the media container format. The attributes are:
+      * format - format (short) name (eg. "ogg")
+      * fullname - format full (descriptive) name
+      * bitrate - total bitrate (bps)
+      * duration - media duration in seconds
+      * filesize - file size
+    """
+
+    def __init__(self):
+        self.format = None
+        self.fullname = None
+        self.bitrate = None
+        self.duration = None
+        self.filesize = None
+
+    def parse_ffprobe(self, key, val):
+        """
+        Parse raw ffprobe output (key=value).
+        """
+        if key == 'format_name':
+            self.format = val
+        elif key == 'format_long_name':
+            self.fullname = val
+        elif key == 'bit_rate':
+            self.bitrate = MediaStreamInfo.parse_float(val, None)
+        elif key == 'duration':
+            self.duration = MediaStreamInfo.parse_float(val, None)
+        elif key == 'size':
+            self.size = MediaStreamInfo.parse_float(val, None)
+
+    def __repr__(self):
+        if self.duration is None:
+            return 'MediaFormatInfo(format=%s)' % self.format
+        return 'MediaFormatInfo(format=%s, duration=%.2f)' % (self.format,
+                                                              self.duration)
+
+
+class MediaStreamInfo(object):
+    """
+    Describes one stream inside a media file. The general
+    attributes are:
+      * index - stream index inside the container (0-based)
+      * type - stream type, either 'audio' or 'video'
+      * codec - codec (short) name (e.g "vorbis", "theora")
+      * codec_desc - codec full (descriptive) name
+      * duration - stream duration in seconds
+      * metadata - optional metadata associated with a video or audio stream
+      * bitrate - stream bitrate in bytes/second
+      * attached_pic - (0, 1 or None) is stream a poster image? (e.g. in mp3)
+    Video-specific attributes are:
+      * video_width - width of video in pixels
+      * video_height - height of video in pixels
+      * video_fps - average frames per second
+    Audio-specific attributes are:
+      * audio_channels - the number of channels in the stream
+      * audio_samplerate - sample rate (Hz)
+    """
+
+    def __init__(self):
+        self.index = None
+        self.type = None
+        self.codec = None
+        self.codec_desc = None
+        self.duration = None
+        self.bitrate = None
+        self.video_width = None
+        self.video_height = None
+        self.video_fps = None
+        self.audio_channels = None
+        self.audio_samplerate = None
+        self.attached_pic = None
+        self.sub_forced = None
+        self.sub_default = None
+        self.metadata = {}
+
+    @staticmethod
+    def parse_float(val, default=0.0):
+        try:
+            return float(val)
+        except:
+            return default
+
+    @staticmethod
+    def parse_int(val, default=0):
+        try:
+            return int(val)
+        except:
+            return default
+
+    def parse_ffprobe(self, key, val):
+        """
+        Parse raw ffprobe output (key=value).
+        """
+
+        if key == 'index':
+            self.index = self.parse_int(val)
+        elif key == 'codec_type':
+            self.type = val
+        elif key == 'codec_name':
+            self.codec = val
+        elif key == 'codec_long_name':
+            self.codec_desc = val
+        elif key == 'duration':
+            self.duration = self.parse_float(val)
+        elif key == 'bit_rate':
+            self.bitrate = self.parse_int(val, None)
+        elif key == 'width':
+            self.video_width = self.parse_int(val)
+        elif key == 'height':
+            self.video_height = self.parse_int(val)
+        elif key == 'channels':
+            self.audio_channels = self.parse_int(val)
+        elif key == 'sample_rate':
+            self.audio_samplerate = self.parse_float(val)
+        elif key == 'DISPOSITION:attached_pic':
+            self.attached_pic = self.parse_int(val)
+
+        if key.startswith('TAG:'):
+            key = key.split('TAG:')[1]
+            value = val
+            self.metadata[key] = value
+
+        if self.type == 'audio':
+            if key == 'avg_frame_rate':
+                if '/' in val:
+                    n, d = val.split('/')
+                    n = self.parse_float(n)
+                    d = self.parse_float(d)
+                    if n > 0.0 and d > 0.0:
+                        self.video_fps = float(n) / float(d)
+                elif '.' in val:
+                    self.video_fps = self.parse_float(val)
+
+        if self.type == 'video':
+            if key == 'r_frame_rate':
+                if '/' in val:
+                    n, d = val.split('/')
+                    n = self.parse_float(n)
+                    d = self.parse_float(d)
+                    if n > 0.0 and d > 0.0:
+                        self.video_fps = float(n) / float(d)
+                elif '.' in val:
+                    self.video_fps = self.parse_float(val)
+
+        if self.type == 'subtitle':
+            if key == 'disposition:forced':
+                self.sub_forced = self.parse_int(val)
+            if key == 'disposition:default':
+                self.sub_default = self.parse_int(val)
+
+
+    def __repr__(self):
+        d = ''
+        metadata_str = ['%s=%s' % (key, value) for key, value
+                        in self.metadata.items()]
+        metadata_str = ', '.join(metadata_str)
+
+        if self.type == 'audio':
+            d = 'type=%s, codec=%s, channels=%d, rate=%.0f' % (self.type,
+                self.codec, self.audio_channels, self.audio_samplerate)
+        elif self.type == 'video':
+            d = 'type=%s, codec=%s, width=%d, height=%d, fps=%.1f' % (
+                self.type, self.codec, self.video_width, self.video_height,
+                self.video_fps)
+        elif self.type == 'subtitle':
+            d = 'type=%s, codec=%s' % (self.type, self.codec)
+        if self.bitrate is not None:
+            d += ', bitrate=%d' % self.bitrate
+
+        if self.metadata:
+            value = 'MediaStreamInfo(%s, %s)' % (d, metadata_str)
+        else:
+            value = 'MediaStreamInfo(%s)' % d
+
+        return value
+
+
+class MediaInfo(object):
+    """
+    Information about media object, as parsed by ffprobe.
+    The attributes are:
+      * format - a MediaFormatInfo object
+      * streams - a list of MediaStreamInfo objects
+    """
+
+    def __init__(self, posters_as_video=True):
+        """
+        :param posters_as_video: Take poster images (mainly for audio files) as
+            A video stream, defaults to True
+        """
+        self.format = MediaFormatInfo()
+        self.posters_as_video = posters_as_video
+        self.streams = []
+
+    def parse_ffprobe(self, raw):
+        """
+        Parse raw ffprobe output.
+        """
+        in_format = False
+        current_stream = None
+
+        for line in raw.split('\n'):
+            line = line.strip()
+            if line == '':
+                continue
+            elif line == '[STREAM]':
+                current_stream = MediaStreamInfo()
+            elif line == '[/STREAM]':
+                if current_stream.type:
+                    self.streams.append(current_stream)
+                current_stream = None
+            elif line == '[FORMAT]':
+                in_format = True
+            elif line == '[/FORMAT]':
+                in_format = False
+            elif '=' in line:
+                k, v = line.split('=', 1)
+                k = k.strip()
+                v = v.strip()
+                if current_stream:
+                    current_stream.parse_ffprobe(k, v)
+                elif in_format:
+                    self.format.parse_ffprobe(k, v)
+
+    def __repr__(self):
+        return 'MediaInfo(format=%s, streams=%s)' % (repr(self.format),
+                                                     repr(self.streams))
+
+    @property
+    def video(self):
+        """
+        First video stream, or None if there are no video streams.
+        """
+        for s in self.streams:
+            if s.type == 'video' and (self.posters_as_video
+                                      or not s.attached_pic):
+                return s
+        return None
+
+    @property
+    def posters(self):
+        return [s for s in self.streams if s.attached_pic]
+
+    @property
+    def audio(self):
+        """
+        First audio stream, or None if there are no audio streams.
+        """
+        for s in self.streams:
+            if s.type == 'audio':
+                return s
+        return None
+
+
+class FFMpeg(object):
+    """
+    FFMPeg wrapper object, takes care of calling the ffmpeg binaries,
+    passing options and parsing the output.
+
+    >>> f = FFMpeg()
+    """
+    DEFAULT_JPEG_QUALITY = 4
+
+    def __init__(self, ffmpeg_path=None, ffprobe_path=None):
+        """
+        Initialize a new FFMpeg wrapper object. Optional parameters specify
+        the paths to ffmpeg and ffprobe utilities.
+        """
+
+        def which(name):
+            path = os.environ.get('PATH', os.defpath)
+            for d in path.split(':'):
+                fpath = os.path.join(d, name)
+                if os.path.exists(fpath) and os.access(fpath, os.X_OK):
+                    return fpath
+            return None
+
+        if ffmpeg_path is None:
+            ffmpeg_path = 'ffmpeg'
+
+        if ffprobe_path is None:
+            ffprobe_path = 'ffprobe'
+
+        if '/' not in ffmpeg_path:
+            ffmpeg_path = which(ffmpeg_path) or ffmpeg_path
+        if '/' not in ffprobe_path:
+            ffprobe_path = which(ffprobe_path) or ffprobe_path
+
+        self.ffmpeg_path = ffmpeg_path
+        self.ffprobe_path = ffprobe_path
+
+        if not os.path.exists(self.ffmpeg_path):
+            raise FFMpegError("ffmpeg binary not found: " + self.ffmpeg_path)
+
+        if not os.path.exists(self.ffprobe_path):
+            raise FFMpegError("ffprobe binary not found: " + self.ffprobe_path)
+
+    @staticmethod
+    def _spawn(cmds):
+        logger.debug('Spawning ffmpeg with command: ' + ' '.join(cmds))
+        return Popen(cmds, shell=False, stdin=PIPE, stdout=PIPE, stderr=PIPE,
+                     close_fds=True)
+
+    def probe(self, fname, posters_as_video=True):
+        """
+        Examine the media file and determine its format and media streams.
+        Returns the MediaInfo object, or None if the specified file is
+        not a valid media file.
+
+        >>> info = FFMpeg().probe('test1.ogg')
+        >>> info.format
+        'ogg'
+        >>> info.duration
+        33.00
+        >>> info.video.codec
+        'theora'
+        >>> info.video.width
+        720
+        >>> info.video.height
+        400
+        >>> info.audio.codec
+        'vorbis'
+        >>> info.audio.channels
+        2
+        :param posters_as_video: Take poster images (mainly for audio files) as
+            A video stream, defaults to True
+        """
+
+        if not os.path.exists(fname):
+            return None
+
+        info = MediaInfo(posters_as_video)
+
+        p = self._spawn([self.ffprobe_path,
+                         '-show_format', '-show_streams', fname])
+        stdout_data, _ = p.communicate()
+        stdout_data = stdout_data.decode(console_encoding)
+        info.parse_ffprobe(stdout_data)
+
+        if not info.format.format and len(info.streams) == 0:
+            return None
+
+        return info
+
+    def convert(self, infile, outfile, opts, timeout=10):
+        """
+        Convert the source media (infile) according to specified options
+        (a list of ffmpeg switches as strings) and save it to outfile.
+
+        Convert returns a generator that needs to be iterated to drive the
+        conversion process. The generator will periodically yield timecode
+        of currently processed part of the file (ie. at which second in the
+        content is the conversion process currently).
+
+        The optional timeout argument specifies how long should the operation
+        be blocked in case ffmpeg gets stuck and doesn't report back. See
+        the documentation in Converter.convert() for more details about this
+        option.
+
+        >>> conv = FFMpeg().convert('test.ogg', '/tmp/output.mp3',
+        ...    ['-acodec libmp3lame', '-vn'])
+        >>> for timecode in conv:
+        ...    pass # can be used to inform the user about conversion progress
+
+        """
+        if not os.path.exists(infile):
+            raise FFMpegError("Input file doesn't exist: " + infile)
+
+        cmds = [self.ffmpeg_path, '-i', infile]
+        cmds.extend(opts)
+        cmds.extend(['-y', outfile])
+
+        if timeout:
+            def on_sigalrm(*_):
+                signal.signal(signal.SIGALRM, signal.SIG_DFL)
+                raise Exception('timed out while waiting for ffmpeg')
+
+            signal.signal(signal.SIGALRM, on_sigalrm)
+
+        try:
+            p = self._spawn(cmds)
+        except OSError:
+            raise FFMpegError('Error while calling ffmpeg binary')
+
+        yielded = False
+        buf = ''
+        total_output = ''
+        pat = re.compile(r'time=([0-9.:]+) ')
+        while True:
+            if timeout:
+                signal.alarm(timeout)
+
+            ret = p.stderr.read(10)
+
+            if timeout:
+                signal.alarm(0)
+
+            if not ret:
+                break
+
+            ret = ret.decode(console_encoding)
+            total_output += ret
+            buf += ret
+            if '\r' in buf:
+                line, buf = buf.split('\r', 1)
+
+                tmp = pat.findall(line)
+                if len(tmp) == 1:
+                    timespec = tmp[0]
+                    if ':' in timespec:
+                        timecode = 0
+                        for part in timespec.split(':'):
+                            timecode = 60 * timecode + float(part)
+                    else:
+                        timecode = float(tmp[0])
+                    yielded = True
+                    yield timecode
+
+        if timeout:
+            signal.signal(signal.SIGALRM, signal.SIG_DFL)
+
+        p.communicate()  # wait for process to exit
+
+        if total_output == '':
+            raise FFMpegError('Error while calling ffmpeg binary')
+
+        cmd = ' '.join(cmds)
+        if '\n' in total_output:
+            line = total_output.split('\n')[-2]
+
+            if line.startswith('Received signal'):
+                # Received signal 15: terminating.
+                raise FFMpegConvertError(line.split(':')[0], cmd, total_output, pid=p.pid)
+            if line.startswith(infile + ': '):
+                err = line[len(infile) + 2:]
+                raise FFMpegConvertError('Encoding error', cmd, total_output,
+                                         err, pid=p.pid)
+            if line.startswith('Error while '):
+                raise FFMpegConvertError('Encoding error', cmd, total_output,
+                                         line, pid=p.pid)
+            if not yielded:
+                raise FFMpegConvertError('Unknown ffmpeg error', cmd,
+                                         total_output, line, pid=p.pid)
+        if p.returncode != 0:
+            raise FFMpegConvertError('Exited with code %d' % p.returncode, cmd,
+                                     total_output, pid=p.pid)
+
+    def thumbnail(self, fname, time, outfile, size=None, quality=DEFAULT_JPEG_QUALITY):
+        """
+        Create a thumbnal of media file, and store it to outfile
+        @param time: time point (in seconds) (float or int)
+        @param size: Size, if specified, is WxH of the desired thumbnail.
+            If not specified, the video resolution is used.
+        @param quality: quality of jpeg file in range 2(best)-31(worst)
+            recommended range: 2-6
+
+        >>> FFMpeg().thumbnail('test1.ogg', 5, '/tmp/shot.png', '320x240')
+        """
+        return self.thumbnails(fname, [(time, outfile, size, quality)])
+
+    def thumbnails(self, fname, option_list):
+        """
+        Create one or more thumbnails of video.
+        @param option_list: a list of tuples like:
+            (time, outfile, size=None, quality=DEFAULT_JPEG_QUALITY)
+            see documentation of `converter.FFMpeg.thumbnail()` for details.
+
+        >>> FFMpeg().thumbnails('test1.ogg', [(5, '/tmp/shot.png', '320x240'),
+        >>>                                   (10, '/tmp/shot2.png', None, 5)])
+        """
+        if not os.path.exists(fname):
+            raise IOError('No such file: ' + fname)
+
+        cmds = [self.ffmpeg_path, '-i', fname, '-y', '-an']
+        for thumb in option_list:
+            if len(thumb) > 2 and thumb[2]:
+                cmds.extend(['-s', str(thumb[2])])
+
+            cmds.extend([
+                '-f', 'image2', '-vframes', '1',
+                '-ss', str(thumb[0]), thumb[1],
+                '-q:v', str(FFMpeg.DEFAULT_JPEG_QUALITY if len(thumb) < 4 else str(thumb[3])),
+            ])
+
+        p = self._spawn(cmds)
+        _, stderr_data = p.communicate()
+        if stderr_data == '':
+            raise FFMpegError('Error while calling ffmpeg binary')
+        stderr_data.decode(console_encoding)
+        if any(not os.path.exists(option[1]) for option in option_list):
+            raise FFMpegError('Error creating thumbnail: %s' % stderr_data)
diff --git a/plugins/videos/formats.py b/plugins/videos/formats.py
new file mode 100644
index 0000000..ce11c83
--- /dev/null
+++ b/plugins/videos/formats.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python
+
+
+class BaseFormat(object):
+    """
+    Base format class.
+
+    Supported formats are: ogg, avi, mkv, webm, flv, mov, mp4, mpeg
+    """
+
+    format_name = None
+    ffmpeg_format_name = None
+
+    def parse_options(self, opt):
+        if 'format' not in opt or opt.get('format') != self.format_name:
+            raise ValueError('invalid Format format')
+        return ['-f', self.ffmpeg_format_name]
+
+
+class OggFormat(BaseFormat):
+    """
+    Ogg container format, mostly used with Vorbis and Theora.
+    """
+    format_name = 'ogg'
+    ffmpeg_format_name = 'ogg'
+
+
+class AviFormat(BaseFormat):
+    """
+    Avi container format, often used vith DivX video.
+    """
+    format_name = 'avi'
+    ffmpeg_format_name = 'avi'
+
+
+class MkvFormat(BaseFormat):
+    """
+    Matroska format, often used with H.264 video.
+    """
+    format_name = 'mkv'
+    ffmpeg_format_name = 'matroska'
+
+
+class WebmFormat(BaseFormat):
+    """
+    WebM is Google's variant of Matroska containing only
+    VP8 for video and Vorbis for audio content.
+    """
+    format_name = 'webm'
+    ffmpeg_format_name = 'webm'
+
+
+class FlvFormat(BaseFormat):
+    """
+    Flash Video container format.
+    """
+    format_name = 'flv'
+    ffmpeg_format_name = 'flv'
+
+
+class MovFormat(BaseFormat):
+    """
+    Mov container format, used mostly with H.264 video
+    content, often for mobile platforms.
+    """
+    format_name = 'mov'
+    ffmpeg_format_name = 'mov'
+
+
+class Mp4Format(BaseFormat):
+    """
+    Mp4 container format, the default Format for H.264
+    video content.
+    """
+    format_name = 'mp4'
+    ffmpeg_format_name = 'mp4'
+
+
+class MpegFormat(BaseFormat):
+    """
+    MPEG(TS) container, used mainly for MPEG 1/2 video codecs.
+    """
+    format_name = 'mpg'
+    ffmpeg_format_name = 'mpegts'
+
+
+class Mp3Format(BaseFormat):
+    """
+    Mp3 container, used audio-only mp3 files
+    """
+    format_name = 'mp3'
+    ffmpeg_format_name = 'mp3'
+
+
+format_list = [
+    OggFormat, AviFormat, MkvFormat, WebmFormat, FlvFormat,
+    MovFormat, Mp4Format, MpegFormat, Mp3Format
+]
diff --git a/plugins/videos/videos.py b/plugins/videos/videos.py
new file mode 100644
index 0000000..67a27bc
--- /dev/null
+++ b/plugins/videos/videos.py
@@ -0,0 +1,691 @@
+from __future__ import unicode_literals
+
+import datetime
+import itertools
+import json
+import logging
+import multiprocessing
+import os
+import pprint
+import re
+import sys
+
+from .avcodecs import video_codec_list, audio_codec_list, subtitle_codec_list
+from .formats import format_list
+from .ffmpeg import FFMpeg, FFMpegError, FFMpegConvertError
+
+# Add pelican support
+from pelican.generators import ArticlesGenerator
+from pelican.generators import PagesGenerator
+from pelican.settings import DEFAULT_CONFIG
+from pelican import signals
+from pelican.utils import pelican_open
+
+logger = logging.getLogger(__name__)
+
+class ConverterError(Exception):
+    pass
+
+class Converter(object):
+    """
+    Converter class, encapsulates formats and codecs.
+
+    >>> c = Converter()
+    """
+
+    def __init__(self, ffmpeg_path=None, ffprobe_path=None):
+        """
+        Initialize a new Converter object.
+        """
+
+        self.ffmpeg = FFMpeg(ffmpeg_path=ffmpeg_path,
+                             ffprobe_path=ffprobe_path)
+        self.video_codecs = {}
+        self.audio_codecs = {}
+        self.subtitle_codecs = {}
+        self.formats = {}
+
+        for cls in audio_codec_list:
+            name = cls.codec_name
+            self.audio_codecs[name] = cls
+
+        for cls in video_codec_list:
+            name = cls.codec_name
+            self.video_codecs[name] = cls
+
+        for cls in subtitle_codec_list:
+            name = cls.codec_name
+            self.subtitle_codecs[name] = cls
+
+        for cls in format_list:
+            name = cls.format_name
+            self.formats[name] = cls
+
+    def parse_options(self, opt, twopass=None):
+        """
+        Parse format/codec options and prepare raw ffmpeg option list.
+        """
+        if not isinstance(opt, dict):
+            raise ConverterError('Invalid output specification')
+
+        if 'format' not in opt:
+            raise ConverterError('Format not specified')
+
+        f = opt['format']
+        if f not in self.formats:
+            raise ConverterError('Requested unknown format: ' + str(f))
+
+        format_options = self.formats[f]().parse_options(opt)
+        if format_options is None:
+            raise ConverterError('Unknown container format error')
+
+        if 'audio' not in opt and 'video' not in opt:
+            raise ConverterError('Neither audio nor video streams requested')
+
+        # audio options
+        if 'audio' not in opt or twopass == 1:
+            opt_audio = {'codec': None}
+        else:
+            opt_audio = opt['audio']
+            if not isinstance(opt_audio, dict) or 'codec' not in opt_audio:
+                raise ConverterError('Invalid audio codec specification')
+
+        c = opt_audio['codec']
+        if c not in self.audio_codecs:
+            raise ConverterError('Requested unknown audio codec ' + str(c))
+
+        audio_options = self.audio_codecs[c]().parse_options(opt_audio)
+        if audio_options is None:
+            raise ConverterError('Unknown audio codec error')
+
+        # video options
+        if 'video' not in opt:
+            opt_video = {'codec': None}
+        else:
+            opt_video = opt['video']
+            if not isinstance(opt_video, dict) or 'codec' not in opt_video:
+                raise ConverterError('Invalid video codec specification')
+
+        c = opt_video['codec']
+        if c not in self.video_codecs:
+            raise ConverterError('Requested unknown video codec ' + str(c))
+
+        video_options = self.video_codecs[c]().parse_options(opt_video)
+        if video_options is None:
+            raise ConverterError('Unknown video codec error')
+
+        if 'subtitle' not in opt:
+            opt_subtitle = {'codec': None}
+        else:
+            opt_subtitle = opt['subtitle']
+            if not isinstance(opt_subtitle, dict) or 'codec' not in opt_subtitle:
+                raise ConverterError('Invalid subtitle codec specification')
+
+        c = opt_subtitle['codec']
+        if c not in self.subtitle_codecs:
+            raise ConverterError('Requested unknown subtitle codec ' + str(c))
+
+        subtitle_options = self.subtitle_codecs[c]().parse_options(opt_subtitle)
+        if subtitle_options is None:
+            raise ConverterError('Unknown subtitle codec error')
+
+        if 'map' in opt:
+            m = opt['map']
+            if not type(m) == int:
+                raise ConverterError('map needs to be int')
+            else:
+                format_options.extend(['-map', str(m)])
+
+
+        # aggregate all options
+        optlist = audio_options + video_options + subtitle_options + format_options
+
+        if twopass == 1:
+            optlist.extend(['-pass', '1'])
+        elif twopass == 2:
+            optlist.extend(['-pass', '2'])
+
+        return optlist
+
+    def convert(self, infile, outfile, options, twopass=False, timeout=10):
+        """
+        Convert media file (infile) according to specified options, and
+        save it to outfile. For two-pass encoding, specify the pass (1 or 2)
+        in the twopass parameter.
+
+        Options should be passed as a dictionary. The keys are:
+            * format (mandatory, string) - container format; see
+              formats.BaseFormat for list of supported formats
+            * audio (optional, dict) - audio codec and options; see
+              avcodecs.AudioCodec for list of supported options
+            * video (optional, dict) - video codec and options; see
+              avcodecs.VideoCodec for list of supported options
+            * map (optional, int) - can be used to map all content of stream 0
+
+        Multiple audio/video streams are not supported. The output has to
+        have at least an audio or a video stream (or both).
+
+        Convert returns a generator that needs to be iterated to drive the
+        conversion process. The generator will periodically yield timecode
+        of currently processed part of the file (ie. at which second in the
+        content is the conversion process currently).
+
+        The optional timeout argument specifies how long should the operation
+        be blocked in case ffmpeg gets stuck and doesn't report back. This
+        doesn't limit the total conversion time, just the amount of time
+        Converter will wait for each update from ffmpeg. As it's usually
+        less than a second, the default of 10 is a reasonable default. To
+        disable the timeout, set it to None. You may need to do this if
+        using Converter in a threading environment, since the way the
+        timeout is handled (using signals) has special restriction when
+        using threads.
+
+        >>> conv = Converter().convert('test1.ogg', '/tmp/output.mkv', {
+        ...    'format': 'mkv',
+        ...    'audio': { 'codec': 'aac' },
+        ...    'video': { 'codec': 'h264' }
+        ... })
+
+        >>> for timecode in conv:
+        ...   pass # can be used to inform the user about the progress
+        """
+
+        if not isinstance(options, dict):
+            raise ConverterError('Invalid options')
+
+        if not os.path.exists(infile):
+            raise ConverterError("Source file doesn't exist: " + infile)
+
+        info = self.ffmpeg.probe(infile)
+        if info is None:
+            raise ConverterError("Can't get information about source file")
+
+        if not info.video and not info.audio:
+            raise ConverterError('Source file has no audio or video streams')
+
+        if info.video and 'video' in options:
+            options = options.copy()
+            v = options['video'] = options['video'].copy()
+            v['src_width'] = info.video.video_width
+            v['src_height'] = info.video.video_height
+
+        if info.format.duration < 0.01:
+            raise ConverterError('Zero-length media')
+
+        if twopass:
+            optlist1 = self.parse_options(options, 1)
+            for timecode in self.ffmpeg.convert(infile, outfile, optlist1,
+                                                timeout=timeout):
+                yield int((50.0 * timecode) / info.format.duration)
+
+            optlist2 = self.parse_options(options, 2)
+            for timecode in self.ffmpeg.convert(infile, outfile, optlist2,
+                                                timeout=timeout):
+                yield int(50.0 + (50.0 * timecode) / info.format.duration)
+        else:
+            optlist = self.parse_options(options, twopass)
+            for timecode in self.ffmpeg.convert(infile, outfile, optlist,
+                                                timeout=timeout):
+                yield int((100.0 * timecode) / info.format.duration)
+
+    def probe(self, fname, posters_as_video=True):
+        """
+        Examine the media file. See the documentation of
+        converter.FFMpeg.probe() for details.
+
+        :param posters_as_video: Take poster images (mainly for audio files) as
+            A video stream, defaults to True
+        """
+        return self.ffmpeg.probe(fname, posters_as_video)
+
+    def thumbnail(self, fname, time, outfile, size=None, quality=FFMpeg.DEFAULT_JPEG_QUALITY):
+        """
+        Create a thumbnail of the media file. See the documentation of
+        converter.FFMpeg.thumbnail() for details.
+        """
+        return self.ffmpeg.thumbnail(fname, time, outfile, size, quality)
+
+    def thumbnails(self, fname, option_list):
+        """
+        Create one or more thumbnail of the media file. See the documentation
+        of converter.FFMpeg.thumbnails() for details.
+        """
+        return self.ffmpeg.thumbnails(fname, option_list)
+
+def initialized(pelican):
+    p = os.path.expanduser('~/Videos')
+
+    DEFAULT_CONFIG.setdefault('VIDEO_LIBRARY', p)
+    DEFAULT_CONFIG.setdefault('VIDEO_GALLERY', (720, 400, 100))
+    DEFAULT_CONFIG.setdefault('VIDEO_ARTICLE', (720, 400, 100))
+    DEFAULT_CONFIG.setdefault('VIDEO_FORMAT', 'mp4')
+    DEFAULT_CONFIG.setdefault('VIDEO_VCODEC', 'h264')
+    DEFAULT_CONFIG.setdefault('VIDEO_ACODEC', 'aac')
+    DEFAULT_CONFIG.setdefault('VIDEO_SAMPLERATE', 11025)
+    DEFAULT_CONFIG.setdefault('VIDEO_CHANNELS', 2)
+    DEFAULT_CONFIG.setdefault('VIDEO_CONVERT_JOBS', 1)
+    DEFAULT_CONFIG.setdefault('VIDEO_EXCLUDE', [])
+    DEFAULT_CONFIG.setdefault('VIDEO_EXCLUDEALL', False)
+
+    DEFAULT_CONFIG['queue_convert'] = {}
+    DEFAULT_CONFIG['created_galleries'] = {}
+    DEFAULT_CONFIG['plugin_dir'] = os.path.dirname(os.path.realpath(__file__))
+
+    if pelican:
+        pelican.settings.setdefault('VIDEO_LIBRARY', p)
+        pelican.settings.setdefault('VIDEO_GALLERY', (720, 400, 100))
+        pelican.settings.setdefault('VIDEO_ARTICLE', (720, 400, 100))
+        pelican.settings.setdefault('VIDEO_FORMAT', 'mp4')
+        pelican.settings.setdefault('VIDEO_VCODEC', 'h264')
+        pelican.settings.setdefault('VIDEO_ACODEC', 'aac')
+        pelican.settings.setdefault('VIDEO_SAMPLERATE', 11025)
+        pelican.settings.setdefault('VIDEO_CHANNELS', 2)
+        pelican.settings.setdefault('VIDEO_CONVERT_JOBS', 1)
+        # Need to change type, VIDEO_EXCLUDE must be iterable
+        if pelican.settings['VIDEO_EXCLUDE'] is None:
+            pelican.settings['VIDEO_EXCLUDE'] = []
+
+        video_excludeall = pelican.settings['VIDEO_EXCLUDEALL'] == '1'
+        pelican.settings['VIDEO_EXCLUDEALL'] = video_excludeall
+
+def read_notes(filename, msg=None):
+    notes = {}
+    try:
+        with pelican_open(filename) as text:
+            for line in text.splitlines():
+                if line.startswith('#'):
+                    continue
+                m = line.split(':', 1)
+                if len(m) > 1:
+                    pic = m[0].strip()
+                    note = m[1].strip()
+                    if pic and note:
+                        notes[pic] = note
+                else:
+                    notes[line] = ''
+    except Exception as e:
+        if msg:
+            logger.info('videos: {} at file {}'.format(msg, filename))
+
+        debug_msg = 'videos: read_notes issue: {} at file {} {}'
+        logger.debug(debug_msg.format(msg, filename, e))
+    return notes
+
+def build_license(license, author):
+    year = datetime.datetime.now().year
+    license_file = os.path.join(DEFAULT_CONFIG['plugin_dir'], 'licenses.json')
+
+    with open(license_file) as data_file:
+        licenses = json.load(data_file)
+
+    if any(license in k for k in licenses):
+        return licenses[license]['Text'].format(Author = author,
+                                                Year = year,
+                                                URL = licenses[license]['URL'])
+    else:
+        license_line = 'Copyright {Year} {Author}, All Rights Reserved'
+        return license_line.format(Author = author, Year = year)
+
+def convert_worker(orig, converted, spec, settings):
+    directory = os.path.split(converted)[0]
+
+    if not os.path.exists(directory):
+        try:
+            os.makedirs(directory)
+        except Exception:
+            logger.exception('videos: could not create {}'.format(directory))
+    else:
+        debug_msg = 'videos: directory already exists at {}'
+        logger.debug(debug_msg.format(os.path.split(converted)[0]))
+
+    try:
+        c = Converter() # FIXME: is Converter thread safe?
+        conv = c.convert(orig, converted, spec)
+        info_msg = '-> videos: converting ({:3d}%) | {}'
+        for t in conv:
+            print(info_msg.format(t, '*' * int(t/2)), end='\r')
+        print(info_msg.format(100, '*' * int(t/2) + ' Done'), end='\n')
+    except Exception as e:
+        error_msg = 'videos: could not convert {} {}'
+        logger.exception(error_msg.format(orig, pprint.pformat(e)))
+
+def sort_input_spec(input_spec):
+    # H > W
+    if input_spec[1] > input_spec[0]:
+        return (input_spec[1], input_spec[0], input_spec[2])
+    return input_spec 
+
+def compute_output_spec(video_info, input_vspec, settings):
+    # To make sure that original format is preserved, then regardless
+    # of the original format, always sort what was requested.
+    input_vspec = sort_input_spec(input_vspec)
+    output_spec = {
+        'format': settings['VIDEO_FORMAT'],
+        'audio': {
+            'codec': settings['VIDEO_ACODEC'],
+            'samplerate': settings['VIDEO_SAMPLERATE'],
+            'channels' : settings['VIDEO_CHANNELS']
+        },
+        'video': {
+            'codec': settings['VIDEO_VCODEC'],
+            'width': input_vspec[0],
+            'height': input_vspec[1],
+            'fps' : video_info[2]
+        }
+    }
+
+    # portrait
+    if video_info[1] > video_info[0]:
+        output_spec['video']['width'] = input_vspec[1]
+        output_spec['video']['height'] = input_vspec[0]
+
+    # landscape or square
+    return output_spec
+
+def get_video_info(info):
+    try:
+        if info is None:
+            logger.error('videos: no media info')
+            return None
+
+        # The API doesn't support more than 2 streams per file
+        v_stream = info.streams[0]
+        a_stream = info.streams[1]
+
+        if v_stream is None or a_stream is None:
+            logger.error('videos: no AV stream')
+            return None
+
+        return (v_stream.video_width, v_stream.video_height, v_stream.video_fps)
+
+    except Exception as e:
+        logger.exception('videos: none probed info {}'.format(pprint.pformat(e)))
+
+def enqueue_convert(orig, converted, input_vspec, settings): 
+    # Unfortunately need to compute width and height now, as templates
+    # need to be aware of the actual spec being computed.
+    try:
+        c = Converter()
+        info = c.probe(orig)
+        video_info = get_video_info(info)
+        output_spec = compute_output_spec(video_info, input_vspec, settings)
+    except Exception as e:
+        error_msg = 'videos: could not probe {} {}'
+        logger.exception(error_msg.format(orig, pprint.pformat(e)))
+
+    if converted not in DEFAULT_CONFIG['queue_convert']:
+        DEFAULT_CONFIG['queue_convert'][converted] = (orig, output_spec)
+        return (output_spec['video']['width'], output_spec['video']['height'])
+
+    if DEFAULT_CONFIG['queue_convert'][converted] != (orig, output_spec):
+        error_msg = 'videos: convert conflict for {}, {}-{} is not {}-{}'
+        item_one = DEFAULT_CONFIG['queue_convert'][converted][0]
+        item_two = DEFAULT_CONFIG['queue_convert'][converted][1]
+        logger.error(error_msg.format(converted, item_one, item_two,
+                                      orig, output_spec))
+
+def convert_videos(generator, writer):
+    if generator.settings['VIDEO_EXCLUDEALL']:
+        logger.warning('videos: skip all galleries')
+        return
+
+    if generator.settings['VIDEO_CONVERT_JOBS'] == -1:
+        generator.settings['VIDEO_CONVERT_JOBS'] = 1
+        debug = True
+    else:
+        debug = False
+
+    pool = multiprocessing.Pool(generator.settings['VIDEO_CONVERT_JOBS'])
+    for converted, what in DEFAULT_CONFIG['queue_convert'].items():
+        converted = os.path.join(generator.output_path, converted)
+        abs_path = os.path.split(converted)[0]
+        basename = os.path.basename(abs_path)
+
+        if basename in generator.settings['VIDEO_EXCLUDE']:
+            logger.warning('videos: skip gallery: {}'.format(basename))
+            continue
+
+        orig, spec = what
+        if (not os.path.isfile(converted) or
+            os.path.getmtime(orig) > os.path.getmtime(converted)):
+            if debug:
+                logger.debug('videos: synchronous convert: {}'.format(orig))
+                convert_worker(orig, converted, spec, generator.settings)
+            else:
+                logger.debug('videos: asynchronous convert: {}'.format(orig))
+                pool.apply_async(convert_worker,
+                                 (orig, converted, spec, generator.settings))
+
+    pool.close()
+    pool.join()
+
+def detect_content(content):
+    hrefs = None
+
+    def replacer(m):
+        what = m.group('what')
+        value = m.group('value')
+        tag = m.group('tag')
+        output = m.group(0)
+ 
+        if what in ('video'):
+            if value.startswith('/'):
+                value = value[1:]
+
+            path = os.path.join(os.path.expanduser(settings['VIDEO_LIBRARY']),
+                                value)
+
+            if os.path.isfile(path):
+                video_prefix = os.path.splitext(value)[0].lower()
+
+                if what == 'video':
+                    ext = settings['VIDEO_FORMAT']
+                    video_article = video_prefix + 'a.' + ext
+                    enqueue_convert(path, os.path.join('videos', video_article),
+                                    settings['VIDEO_ARTICLE'], settings)
+
+                    output = ''.join((
+                        '<',
+                        m.group('tag'),
+                        m.group('attrs_before'),
+                        m.group('src'),
+                        '=',
+                        m.group('quote'),
+                        os.path.join(settings['SITEURL'], 'videos',
+                                     video_article),
+                        m.group('quote'),
+                        m.group('attrs_after'),
+                    ))
+                else:
+                    logger.error('videos: no video {}'.format(path))
+
+        return output
+
+    if hrefs is None:
+        regex = r"""
+            <\s*
+            (?P<tag>[^\s\>]+)  # detect the tag
+            (?P<attrs_before>[^\>]*)
+            (?P<src>href|src)  # match tag with src and href attr
+            \s*=
+            (?P<quote>["\'])  # require value to be quoted
+            (?P<path>{0}(?P<value>.*?))  # the url value
+            (?P=quote)
+            (?P<attrs_after>[^\>]*>)
+        """.format(content.settings['INTRASITE_LINK_REGEX'])
+        hrefs = re.compile(regex, re.X)
+
+    if content._content and ('{video}' in content._content):
+        settings = content.settings
+        content._content = hrefs.sub(replacer, content._content)
+
+def galleries_string_decompose(gallery_string):
+    splitter_regex = re.compile(r'[\s,]*?({video}|{filename})')
+    title_regex = re.compile(r'{(.+)}')
+    galleries = map(unicode.strip if
+                    sys.version_info.major == 2 else
+                    str.strip,
+                    filter(None, splitter_regex.split(gallery_string)))
+    galleries = [gallery[1:] if
+                 gallery.startswith('/') else
+                 gallery for gallery in galleries]
+
+    if len(galleries) % 2 == 0 and ' ' not in galleries:
+        galleries = zip(zip(['type'] * len(galleries[0::2]),
+                            galleries[0::2]),
+                        zip(['location'] * len(galleries[0::2]),
+                            galleries[1::2]))
+        galleries = [dict(gallery) for gallery in galleries]
+
+        for gallery in galleries:
+            title = re.search(title_regex, gallery['location'])
+            if title:
+                gallery['title'] = title.group(1)
+                gallery['location'] = re.sub(title_regex, '',
+                                             gallery['location']).strip()
+            else:
+                gallery['title'] = DEFAULT_CONFIG['VIDEO_GALLERY_TITLE']
+        return galleries
+    else:
+        error_msg = 'videos: unexpected gallery location format!\n{}'
+        logger.error(error_msg.format(pprint.pformat(galleries)))
+
+def process_gallery(generator, content, location):
+    galleries = galleries_string_decompose(location)
+    content.video_gallery = []
+
+    for gallery in galleries:
+        if gallery['location'] in DEFAULT_CONFIG['created_galleries']:
+            item = DEFAULT_CONFIG['created_galleries'][gallery]
+            content.video_gallery.append((gallery['location'], item))
+            continue
+
+        if gallery['type'] == '{video}':
+            path = generator.settings['VIDEO_LIBRARY']
+            dir_gallery = os.path.join(os.path.expanduser(path), gallery['location'])
+            rel_gallery = gallery['location']
+        elif gallery['type'] == '{filename}':
+            base_path = os.path.join(generator.path, content.relative_dir)
+            dir_gallery = os.path.join(base_path, gallery['location'])
+            rel_gallery = os.path.join(content.relative_dir, gallery['location'])
+
+        if os.path.isdir(dir_gallery):
+            logger.info('videos: gallery detected: {}'.format(rel_gallery))
+            dir_video = os.path.join('videos', rel_gallery.lower())
+
+            try:
+                captions = read_notes(os.path.join(dir_gallery, 'captions.txt'),
+                                      msg='videos: no captions for gallery')
+                blacklist = read_notes(os.path.join(dir_gallery, 'blacklist.txt'),
+                                       msg='videos: no blacklist for gallery')
+            except Exception as e:
+                logger.debug('photos: exception {}'.format(pprint.pformat(e)))
+
+            # HTML5 videos tag has several attribs. Here used only:
+            # [({{ title }}, ({{ video }}, {{ width }}, {{ height }}, {{ format }}))]
+            content_gallery = []
+            tag_title = gallery['title']
+
+            # Go over all the videos in dir_gallery
+            for old_video in sorted(os.listdir(dir_gallery)):
+                if old_video.startswith('.'):
+                    continue
+                if old_video.endswith('.txt'):
+                    continue
+                if old_video in blacklist:
+                    continue
+                
+                tag_format = generator.settings['VIDEO_FORMAT']
+                # tag_name currently not passed to the template
+                tag_name = os.path.splitext(old_video)[0].lower() + '.' + tag_format
+                # relative path from /video/...
+                tag_path = os.path.join(dir_video, tag_name)
+                tag_spec = enqueue_convert(os.path.join(dir_gallery, old_video),
+                                           tag_path,
+                                           generator.settings['VIDEO_GALLERY'],
+                                           generator.settings)
+
+                # Add more tag if needed, note: order matters!
+                content_gallery.append((tag_path, tag_spec[0], tag_spec[1], tag_format))
+
+            content.video_gallery.append((tag_title, content_gallery))
+            debug_msg = 'videos: gallery data: {}'
+            logger.debug(debug_msg.format(pprint.pformat(content.video_gallery)))
+            DEFAULT_CONFIG['created_galleries']['gallery'] = content_gallery
+        else:
+            error_msg = 'videos: gallery does not exist: {} at {}'
+            logger.error(error_msg.format(gallery['location'], dir_gallery))
+
+def detect_gallery(generator, content):
+    if 'video-gallery' in content.metadata:
+        gallery = content.metadata.get('video-gallery')
+        if gallery.startswith('{video}') or gallery.startswith('{filename}'):
+            process_gallery(generator, content, gallery)
+        elif gallery:
+            error_msg = 'videos: gallery tag not recognized: {}'
+            logger.error(error_msg.format(gallery))
+
+def video_clipper(x):
+    return x[8:] if x[8] == '/' else x[7:]
+
+def file_clipper(x):
+    return x[11:] if x[10] == '/' else x[10:]
+
+def process_video(generator, content, video_input):
+    if video_input.startswith('{video}'):
+        path = generator.settings['VIDEO_LIBRARY']
+        path = os.path.join(os.path.expanduser(path), video_clipper(video_input))
+        video_name = video_clipper(video_input)
+    elif video_input.startswith('{filename}'):
+        path = os.path.join(generator.path, content.relative_dir,
+                            file_clipper(video_input))
+        video_name = file_clipper(video_input)
+
+    if os.path.isfile(path):
+        ext = generator.settings['VIDEO_FORMAT']
+        video = os.path.splitext(video_name)[0].lower() + 'a.' + ext
+        content.video_file = (os.path.basename(video_name).lower(),
+                              os.path.join('videos', video))
+        
+        enqueue_convert(path, os.path.join('videos', video),
+                        generator.settings['VIDEO_ARTICLE'],
+                        settings)
+    else:
+        error_msg = 'videos: no video for {} at {}'
+        logger.error(error_msg.format(content.source_path, path))
+
+def detect_video(generator, content):
+    video = content.metadata.get('video', None)
+    if video:
+        if video.startswith('{video}') or video.startswith('{filename}'):
+            process_video(generator, content, video)
+        else:
+            error_msg = 'videos: video tag not recognized: {}'
+            logger.error(error_msg.format(video))
+
+def detect_videos_and_galleries(generators):
+    """Runs generator on both pages and articles."""
+    for generator in generators:
+        if isinstance(generator, ArticlesGenerator):
+            for article in itertools.chain(generator.articles,
+                                           generator.translations,
+                                           generator.drafts):
+                detect_video(generator, article)
+                detect_gallery(generator, article)
+        elif isinstance(generator, PagesGenerator):
+            for page in itertools.chain(generator.pages,
+                                        generator.translations,
+                                        generator.hidden_pages):
+                detect_video(generator, page)
+                detect_gallery(generator, page)
+
+def register():
+    """Uses the new style of registration based on GitHub Pelican issue #314."""
+    signals.initialized.connect(initialized)
+    try:
+        signals.content_object_init.connect(detect_content)
+        signals.all_generators_finalized.connect(detect_videos_and_galleries)
+        signals.article_writer_finalized.connect(convert_videos)
+    except Exception as e:
+        except_msg = 'videos: plugin failed to execute: {}'
+        logger.exception(except_msg.format(pprint.pformat(e)))
diff --git a/theme/templates/article.html b/theme/templates/article.html
index 11e32c5..d8bd049 100644
--- a/theme/templates/article.html
+++ b/theme/templates/article.html
@@ -38,6 +38,22 @@
   {% if not DISPLAY_META_ABOVE_ARTICLE %}
     {% include "modules/article_meta.html" %}
   {% endif %}
+  {% if article.video_gallery %}
+  <div class="gallery">
+  {% for title, gallery in article.video_gallery %}
+    <h1>{{ title }}</h1>
+    {% for video, width, height, format in gallery %}
+    <video width="{{ width }}" height="{{ height }}" controls>
+      <source src="{{ SITEURL }}/{{ video }}" type="video/{{ format }}">
+	  Your browser does not support the video tag.
+    </video>
+    {% endfor %}
+  {% endfor %}
+  </div>
+  {% endif %}
+  {% if not DISPLAY_META_ABOVE_ARTICLE %}
+    {% include "modules/article_meta.html" %}
+  {% endif %}
 
   {% if DISQUS_SITENAME %}
   <div id="article_comments">