pelican-subtle-giggi: update release v2
diff --git a/Makefile b/Makefile
index cc1ec0b..5d8677e 100644
--- a/Makefile
+++ b/Makefile
@@ -3,10 +3,18 @@
 PELICANOPTS=
 
 BASEDIR=$(CURDIR)
-INPUTDIR=$(BASEDIR)/content
-OUTPUTDIR=$(BASEDIR)/output
 CONFFILE=$(BASEDIR)/pelicanconf.py
 PUBLISHCONF=$(BASEDIR)/publishconf.py
+OUTPUTDIR=$(BASEDIR)/output
+OUTPUTDIR_THEME=$(OUTPUTDIR)/theme
+OUTPUTDIR_PHOTOS=$(OUTPUTDIR)/photos
+OUTPUTDIR_VIDEOS=$(OUTPUTDIR)/videos
+
+ifeq ($(PELICAN_CONTENT),)
+ INPUTDIR=$(BASEDIR)/content
+else
+ INPUTDIR=$(PELICAN_CONTENT)
+endif
 
 FTP_HOST=localhost
 FTP_USER=anonymous
@@ -27,10 +35,17 @@
 
 GITHUB_PAGES_BRANCH=gh-pages
 
-INSTALLDIR=
-UID="www-data"
-GID="www-data"
-MOD="664"
+INSTALLSUDO ?= 0
+ifeq ($(INSTALLSUDO),1)
+ SUDO := sudo -E
+endif
+INSTALLFLAGS := $(INSTALLFLAGS) -rXaA
+INSTALLDIR ?=
+# Copy the whole directory theme
+INSTALLDIR_THEME ?=
+# Copy contents only
+INSTALLDIR_PHOTOS ?=
+INSTALLDIR_VIDEOS ?=
 
 DEBUG ?= 0
 ifeq ($(DEBUG), 1)
@@ -47,7 +62,12 @@
 	@echo '                                                                          '
 	@echo 'Usage:                                                                    '
 	@echo '   make html                           (re)generate the web site          '
-	@echo '   make clean                          remove the generated files         '
+	@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 '
 	@echo '   make serve [PORT=8000]              serve site at http://localhost:8000'
@@ -61,6 +81,11 @@
 	@echo '   make s3_upload                      upload the web site via S3         '
 	@echo '   make cf_upload                      upload the web site via Cloud Files'
 	@echo '   make github                         upload the web site via gh-pages   '
+	@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   '
 	@echo 'Set the RELATIVE variable to 1 to enable relative urls                    '
@@ -69,8 +94,24 @@
 html:
 	$(PELICAN) $(INPUTDIR) -o $(OUTPUTDIR) -s $(CONFFILE) $(PELICANOPTS)
 
+clean-html:
+	find "$(OUTPUTDIR)" -type f -name *.html -delete
+	find "$(OUTPUTDIR)" -type d -empty -delete
+
+clean-theme:
+	[ ! -d "$(OUTPUTDIR_THEME)" ] || rm -rf $(OUTPUTDIR_THEME)
+
+clean-photos:
+	[ ! -d "$(OUTPUTDIR_PHOTOS)" ] || rm -rf $(OUTPUTDIR_PHOTOS)
+
+clean-videos:
+	[ ! -d "$(OUTPUTDIR_VIDEOS)" ] || rm -rf $(OUTPUTDIR_VIDEOS)
+
 clean:
-	[ ! -d $(OUTPUTDIR) ] || rm -rf $(OUTPUTDIR)
+	[ ! -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)
@@ -126,18 +167,29 @@
 	ghp-import -m "Generate Pelican site" -b $(GITHUB_PAGES_BRANCH) $(OUTPUTDIR)
 	git push origin $(GITHUB_PAGES_BRANCH)
 
-install:
-	@command [ -z "$(INSTALLDIR)" ] && echo "INSTALLDIR unset. i.e. make install INSTALLDIR=\"/path/to/dir\"" && exit 1 ||:
-	@command [ ! -d $(OUTPUTDIR) ] && echo "OUTPUTDIR unset. i.e. make html" && exit 1 ||:
-	@echo "Copying $(OUTPUTDIR) to $(INSTALLDIR)"
-	@command sudo cp -ra $(OUTPUTDIR)/* $(INSTALLDIR) ||:
-	@echo "Setting chown to $(UID):$(GID)"
-	@command sudo chown -R $(UID):$(GID) $(INSTALLDIR) ||:
-	@echo "Setting chmod $(MOD) to files"
-	@command find $(INSTALLDIR) -type f | sudo xargs -i{} chmod $(MOD) {} ||:
-	@echo "Installed files:"
-	@command find $(INSTALLDIR) -type f ||:
-	@echo "Done."
+install-html:
+	[ -d "$(OUTPUTDIR)" ] && [ -d "$(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)" ] && { echo "Nothing to do for: install-photos"; \
+	exit 0; } || [ -d "$(INSTALLDIR_PHOTOS)" ] && \
+	$(SUDO) rsync $(INSTALLFLAGS) $(OUTPUTDIR_PHOTOS)/ $(INSTALLDIR_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 regenerate serve serve-global devserver stopserver publish ssh_upload rsync_upload dropbox_upload ftp_upload s3_upload cf_upload github install
+.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/theme/README.md b/README.md
similarity index 100%
rename from theme/README.md
rename to README.md
diff --git a/content/pages/about.md b/content/pages/about.md
deleted file mode 100644
index 74d7bea..0000000
--- a/content/pages/about.md
+++ /dev/null
@@ -1,3 +0,0 @@
-title: About
-date: 01.01.01
-
diff --git a/content/pages/ftp.md b/content/pages/ftp.md
deleted file mode 100644
index 72f3fe9..0000000
--- a/content/pages/ftp.md
+++ /dev/null
@@ -1,3 +0,0 @@
-title: Ftp
-
-
diff --git a/content/pages/git.md b/content/pages/git.md
deleted file mode 100644
index 5b1329c..0000000
--- a/content/pages/git.md
+++ /dev/null
@@ -1,17 +0,0 @@
-title: Git
-
-### Avrdude-6.3-fork
-
-[Public AVRDUDE fork](https://bitbucket.org/luigi_s/avrdude-6.3-fork/src/master/ "Avrdude-6.3")
-
-### Grbl-v1.1h-fork
-
-[Public GRBL fork](https://bitbucket.org/luigi_s/grbl-v1.1h-fork/src/master/ "Grbl-v1.1h")
-
-### Emacs init.el
-
-[Public EMACS init](https://bitbucket.org/luigi_s/emacstore/src/master/ "Emacstore")
-
-### Pattigrass
-
-[Private PATTIGRASS repo](https://bitbucket.org/luigi_s/pattigrass/src/master/ "Pattigrass")
diff --git a/content/pages/home.md b/content/pages/home.md
deleted file mode 100644
index 9c64a66..0000000
--- a/content/pages/home.md
+++ /dev/null
@@ -1,20 +0,0 @@
-title: 
-date: 09.02.2019
-slug: static-home-page
-URL:
-save_as: index.html
-
-
-*subject*: some   [basics](https://theweek.com/articles/462065/brief-history-yippeekiyay)
-first.
-
-Hello,
-
-As much as obsolete and unlike its  original author, I still love the concept of
-[slow web](https://jackcheng.com/the-slow-web/).   Hence, if we know  each other
-you maybe can access  already what you are looking for  here. Otherwise, drop me
-an email [^1] and you maybe will.
-
-Thanks, see you.
-
-[^1]: Italian, English and I'd love to practise my Spanish.
diff --git a/content/pages/invite.md b/content/pages/invite.md
deleted file mode 100644
index 0d8d347..0000000
--- a/content/pages/invite.md
+++ /dev/null
@@ -1,6 +0,0 @@
-title: Invite
-date: 14.02.2019
-Template: invite
-
-Fill up this form and send it will request an account on giggi.me. It will have
-then to be authorized and activated, you will get an email when that happens.
diff --git a/content/pages/login.md b/content/pages/login.md
deleted file mode 100644
index 2a3adb9..0000000
--- a/content/pages/login.md
+++ /dev/null
@@ -1,6 +0,0 @@
-title: Login
-date: 14.02.2019
-Template: login
-
-Welcome, goto to invite if you'd like an account.
-
diff --git a/content/pages/mail.md b/content/pages/mail.md
deleted file mode 100644
index 52b158c..0000000
--- a/content/pages/mail.md
+++ /dev/null
@@ -1,2 +0,0 @@
-title: Mail
-
diff --git a/content/posts/making-giggi-me.md b/content/posts/making-giggi-me.md
deleted file mode 100644
index 697bb82..0000000
--- a/content/posts/making-giggi-me.md
+++ /dev/null
@@ -1,18 +0,0 @@
-title: Giggi.me instructions.
-subtitle: Sharing how to bring quickly a static site up.
-date: 02.10.19
-summary: *Some brief notes on how Giggi.me was made*
-slug: making-giggi-me
-no: 5
-
-This website is based on [Pelican](https://github.com/getpelican/pelican), which
-uses python  for generating  the final  html output.   Pelican supports  lots of
-themes and tweaks, below are available useful links and some changes made on top
-of it.
-
-1. [Jinja docs](http://jinja.pocoo.org/docs/2.10/templates/#sort)
-2. [Pelican 3.6.3 docs](http://docs.getpelican.com/en/3.6.3/content.html)
-3. [Pelican, using a static page as home](http://docs.getpelican.com/en/3.6.3/faq.html?highlight=faq#how-can-i-use-a-static-page-as-my-home-page)
-4. [Markdown docs](https://markdown-guide.readthedocs.io/en/latest/)
-5. [Markdown docs](https://daringfireball.net/projects/markdown/syntax)
-
diff --git a/content/posts/meet-patti.md b/content/posts/meet-patti.md
deleted file mode 100644
index 07acffc..0000000
--- a/content/posts/meet-patti.md
+++ /dev/null
@@ -1,37 +0,0 @@
-title: Demo Patti moves its first steps
-subtitle: Epic vise for iconic granddad
-date: 09.12.2019
-summary: *Patti baby steps*
-slug: meet-patti
-no: 10
-
-Here is a quick view of the very first test. It was to check whether the frame
-properly aligned worm screw and gears. The reduction ratio in place is 100:1.
-I am driving this with a bare metal RC transmitter/receiver, no MCU at all.
-
-<script src="http://vjs.zencdn.net/4.0/video.js"></script>
-<video id="patti-video-1" class="video-js vjs-default-skin" controls
-preload="auto" width="384" height="683" poster="https://www.giggi.me/videos/demo-patti/videos-1.png" 
-data-setup="{}">
-<source src="https://www.giggi.me/videos/demo-patti/video-1.mp4" type='video/mp4'>
-</video>
-
-Here is with the frame fully assembled. I am driving it using Firefox running on an
-Android tablet. Patti is powered by a Pic32 with a 6LowPan WiFi on-board.
-
-<script src="http://vjs.zencdn.net/4.0/video.js"></script>
-<video id="patti-video-2" class="video-js vjs-default-skin" controls
-preload="auto" width="384" height="683" poster="https://www.giggi.me/videos/demo-patti/videos-2.png" 
-data-setup="{}">
-<source src="https://www.giggi.me/videos/demo-patti/video-2.mp4" type='video/mp4'>
-</video>
-
-Here it's also carrying a brush cutter, which makes it weight about 30 kilos. It's
-driven from Chrome running on a laptop.
-
-<script src="http://vjs.zencdn.net/4.0/video.js"></script>
-<video id="patti-video-3" class="video-js vjs-default-skin" controls
-preload="auto" width="384" height="683" poster="https://www.giggi.me/videos/demo-patti/videos-3.png" 
-data-setup="{}">
-<source src="https://www.giggi.me/videos/demo-patti/video-3.mp4" type='video/mp4'>
-</video>
diff --git a/content/posts/pattigrass-ci40.md b/content/posts/pattigrass-ci40.md
deleted file mode 100644
index c7efe34..0000000
--- a/content/posts/pattigrass-ci40.md
+++ /dev/null
@@ -1,10 +0,0 @@
-category: PattiGrass
-title: 6LowPan server from Ci40 and OpenWRT
-subtitle: There's no help out there
-summary: *Overview of how to setup the Ci40 for Pattigrass*
-slug: pattigrass-ci40
-date: 02.10.19
-no: 2
-
-PattiGrass needs  a server, meaning somebody  who's in charge and  tells what to
-do. Let us see how Imagination's Ci40 does about that.
diff --git a/content/posts/pattigrass-general.md b/content/posts/pattigrass-general.md
deleted file mode 100644
index 1febaa2..0000000
--- a/content/posts/pattigrass-general.md
+++ /dev/null
@@ -1,19 +0,0 @@
-title: PattiGrass
-subtitle: How everything fits together
-date: 02.10.19
-summary: *An overview of PattiGrass*
-slug: pattigrass-general
-no: 1
-
-Definition attempt as follows:
-
-> PattiGrass is an excuse for getting  several things to work together. It comes
-> in the final form of a *lawn mower* and a GUI.
-
-Long  story short,  this project  started _officially_  somewhere about  October
-2017, or at least so it seems to be dated its initial commit.
-
-This page is a general bucket, meaning will try to put together things unrelated
-to any specific  component of PattiGrass.  Hopefully I will  get myself to write
-something down at some point.
-
diff --git a/content/posts/pattigrass-pic32.md b/content/posts/pattigrass-pic32.md
deleted file mode 100644
index 57f79fb..0000000
--- a/content/posts/pattigrass-pic32.md
+++ /dev/null
@@ -1,8 +0,0 @@
-title: pic32 as onboard MCU
-subtitle: 6LowPan Clicker by Michrochip(r)
-date: 02.10.19
-summary: *Onboard MCU - pic32*
-slug: pattigrass-pic32
-no: 3
-
-PattiGrass needs an onboard MCU. Let us see how pic32 does about it.
diff --git a/content/posts/pattigrass-webui.md b/content/posts/pattigrass-webui.md
deleted file mode 100644
index 645160e..0000000
--- a/content/posts/pattigrass-webui.md
+++ /dev/null
@@ -1,10 +0,0 @@
-title: Graphics UI, take the wheels.
-subtitle: Web browser for a responsive GUI
-date: 02.10.19
-summary: *GUI web based for Patti*
-slug: pattigrass-webui
-no: 4
-
-PattiGrass needs  an user  interface. It  doesn't need to  be a  *graphics* user
-interface, it can though. What follows is  about how that is achieved with a web
-browser.
diff --git a/content/posts/routing-giggi-me.md b/content/posts/routing-giggi-me.md
deleted file mode 100644
index e6a8c6d..0000000
--- a/content/posts/routing-giggi-me.md
+++ /dev/null
@@ -1,89 +0,0 @@
-title: Routing hell with Apache and CodeIgniter
-subtitle: When it does not really make sense
-date: 22.06.2019
-summary: *Some notes on to get it to work*
-slug: routing-giggi-me
-no: 6
-
-Taken by the frustration of a  ridicolous amount of time wasted in understanding
-why this thing did not want to run  as expected I will write down how finally it
-did it.
-
-Admittedly I do not  know anything about Apache, PHP and  CodeIgniter as well as
-any viable alternative to them. However, I needed some tool for hosting personal
-contents and I gave them a go.
-
-The goal was to  get something extremely simple running in  a robust manner. I'd
-like to:
-
-1. display friendly URLs
-2. do **not** use .htaccess files
-3. only use virtual hosts
-4. only use rewrite rules as URL manipulation mechanism
-
-The first point above translates in CodeIgniter with routing and removing
-index.php from the requested URI.  Google '*hide index.php CodeIgniter*' to get
-a flavour of it. Once you have done that carry on reading this.
-
-From the official docs[^1] this is what you've got to do:
-
-```
-RewriteEngine On
-RewriteCond %{REQUEST_FILENAME} !-f
-RewriteCond %{REQUEST_FILENAME} !-d
-RewriteRule ^(.*)$ index.php/$1 [L]
-```
-
-It assumes you are using a .htaccess file, it also assumes you are willing to
-scatter dot-hidden-files across the file system, which is also discouraged by
-Apache folks[^2].
-
-I don't want to use it, I'll move it to the vhost. In order to get it right
-though there are a few caveats to be noted. .htaccess lives in a *directory
-context*, so make sure this will be replicated in the vhost, as:
-
-```
-<Directory "/var/www/html">
-	Require all granted # This assumes that /var/www/html can be safely accessed
-</Directory>
-```
-
-Then, RewriteEngine & Co. can be added, but one more thing must be clarified:
-**DO NOT REDIRECT**.  This means, make sure `R` isn't one of the flags you are
-passing to the rewrite rule. This is because you want the rewrite engine to be
-silently manipulating the requested URI server side only, definitely you do not
-want the client to send a new request that includes `index.php/whatever`.
-
-```
-<Directory "/var/www/html">
-	Require all granted
-	RewriteEngine On
-	RewriteCond %{REQUEST_FILENAME} !-f
-	RewriteCond %{REQUEST_FILENAME} !-d
-	RewriteRule ^(.*)$ index.php/$1 [L]
-</Directory>
-```
-
-Now you can tell CodeIgniter not to expect any prefix before the controller is
-invoked, in `config/config.php` remove `index.php`:
-
-```
-- $config['index_page'] = 'index.php';
-+ $config['index_page'] = '';
-```
-
-Last thing to do is to set up some route in order to map a specific requested
-token to the right controller for your case. This is done in `config/routes.php`
-as follows[^3]:
-
-```
-$route['blog/joe'] = 'blogs/users/34';
-$route['product/(:any)'] = 'catalog/product_lookup';
-$route['product/(:num)'] = 'catalog/product_lookup_by_id/$1';
-```
-
-HTH
-
-[^1]: [Removing the index.php file](https://www.codeigniter.com/user_guide/general/urls.html#removing-the-index-php-file)
-[^2]: [Apache htaccess files](https://httpd.apache.org/docs/2.4/howto/htaccess.html#page-header)
-[^3]: [CodeIgniter routing](https://www.codeigniter.com/user_guide/general/routing.html)
diff --git a/content/posts/shocks-abs.md b/content/posts/shocks-abs.md
deleted file mode 100644
index 84f1454..0000000
--- a/content/posts/shocks-abs.md
+++ /dev/null
@@ -1,32 +0,0 @@
-title: Patti shock absorbers
-subtitle: To cut threads on lathe, you must become the lathe
-date: 9.09.2019
-summary: *Working on Patti suspension system*
-slug: patti-shock-abs
-gallery: {photo}shockabs{Assembly parts}
-no: 8
-
-Christmas time, time to wrap Patti's shock absorbers up. Fortunately I had to
-split the work across two holidays, so I did some thinking in the
-meantime. Pretty happy with how they came up.
-
-They allow some tweaking, there is an adjustable pre-load screw and the weight
-force on the spring coils[^1] can be increased or decreased by varying the
-distance that the unit has from the pivot point - overall this mechanism is
-exactly like a nutcracker.
-
-Anyone will notice there is no hydraulics involved. For the time being I did not
-want to over engineer the design, besides there is no real need yet for an
-actual absorbing action. However this might change in future.
-
-I also had to split the rear block into two independent units, including wheel,
-motor and assembly parts.
-
-Pictures do not show batteries, battery trays and electronics box, I still got
-to figure out how to place all of it. They used to sit in the middle of the old
-monolithic rear block.
-
-Material utilised are music wire (for the spring coils), aluminium, brass, then
-bearing, nuts and bolts.
-
-[^1]: [Custom spring retailer](https://www.compressionspring.com/pc169-1156-21000-mw-7250-cg-n-in.html?unit_measure=me)
diff --git a/content/posts/vise-restore.md b/content/posts/vise-restore.md
deleted file mode 100644
index b86f09b..0000000
--- a/content/posts/vise-restore.md
+++ /dev/null
@@ -1,17 +0,0 @@
-title: 1954 Stahl-Titan vise restoration
-subtitle: Epic vise for iconic granddad
-date: 29.08.2019
-summary: *A visaul story-telling*
-slug: vise-restoration
-gallery: {photo}visesbs{Side by side}, {photo}vise{All of them}
-no: 7
-
-This year my granddad turned 90 and I loved to bring this iconic item of my
-childhood back to shine. Youtube was my main source of inspiration, must say,
-*Hand tool rescue* is a great channel and those guys really know what they are
-doing.
-
-I won't be verbose, I am not a professional, only an enthusiast enjoying
-spending his time in the garage.
-
-I plan to improve this article, time permitting.
diff --git a/pelicanconf.py b/pelicanconf.py
index 39a0e23..7f63de0 100644
--- a/pelicanconf.py
+++ b/pelicanconf.py
@@ -1,6 +1,7 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*- #
 from __future__ import unicode_literals
+import os
 
 # For an exhaustive list of available variables and how to use them refer to
 # http://docs.getpelican.com/en/stable/settings.html
@@ -12,19 +13,24 @@
 SITENAME = 'Giggi.me'
 
 # Custom variable, introduced for keeping requests cosistent
-INDEX_LINK_AS = 'blog.html'
+INDEX_LINK_URL = 'blog'
 
 # If set, stop treating index.html as default posts binder
-INDEX_SAVE_AS = INDEX_LINK_AS
+INDEX_SAVE_AS = INDEX_LINK_URL + '.html'
 
 # URL path to the root
-SITEURL = 'https://www.giggi.me'
+_SITEURL = os.getenv('PELICAN_SITEURL')
+SITEURL = 'https://www.' + _SITEURL
 
 # URL path to the theme folder
 THEMEURL = SITEURL
 
 # Local path to the markdown source folder
-PATH = 'content'
+_PATH = os.getenv('PELICAN_CONTENT')
+PATH = _PATH
+
+# Where to output the generated files
+OUTPUT_PATH = 'output'
 
 # Local path to the current theme folder
 THEME = 'theme'
@@ -60,6 +66,7 @@
 SOCIAL = (
     ('Linux', 'https://www.debian.org'),
     ('Mail', 'mailto:luigi.santivetti@gmail.com'),
+    ('Gitles', SITEURL + '/gitles'),
 )
 
 #
@@ -67,13 +74,14 @@
 
 # List of plugins paths utilised by this site
 PLUGIN_PATHS = [
-    'theme/plugins',
+    'plugins',
 ]
 
 # List of plugins names
 PLUGINS = [
     'assets',
     'photos',
+    'videos',
 ]
 
 # Derive categories from the folder name
@@ -90,15 +98,15 @@
 
 # List of menu items
 MENUITEMS = [
-    ('Git', 'page/git.html'),
-    ('Mail', 'page/mail.html'),
-    ('Ftp', 'page/ftp.html'),
-    ('About', 'page/about.html'),
-    ('Invite', 'page/invite.html'),
-    ('Login', 'page/login.html'),
+    ('Git', 'page/git'),
+    ('Mail', 'page/mail'),
+    ('Ftp', 'page/ftp'),
+    ('Invite', 'page/invite'),
+    ('About', 'page/about'),
 ]
 
 # Enable line numbers
+# https://python-markdown.github.io/reference/#markdown
 MARKDOWN = {
     'extension_configs': {
         'markdown.extensions.codehilite': {'css_class': 'highlight', 'linenums' : True},
@@ -115,71 +123,71 @@
 #RELATIVE_URLS = True
 
 # The URL to refer to an article.
-ARTICLE_URL = 'blog/{slug}.html'
+ARTICLE_URL = 'blog/{slug}'
 
 # The place where we will save an article.
 ARTICLE_SAVE_AS = 'blog/{slug}.html'
 
 # The URL to refer to an article which doesn’t use the default language.
-ARTICLE_LANG_URL = 'blog/{slug}-{lang}.html'
+ARTICLE_LANG_URL = 'blog/{slug}-{lang}'
 
 # The place where we will save an article which doesn’t use the default
 # language.
 ARTICLE_LANG_SAVE_AS = 'blog/{slug}-{lang}.html'
 
 # The URL to refer to an article draft.
-DRAFT_URL = 'draft/blog/{slug}.html'
+DRAFT_URL = 'blog/draft/{slug}'
 
 # The place where we will save an article draft.
-DRAFT_SAVE_AS = 'draft/blog/{slug}.html'
+DRAFT_SAVE_AS = 'blog/draft/{slug}.html'
 
 # The URL to refer to an article draft which doesn’t use the default language.
-DRAFT_LANG_URL = 'draft/blog/{slug}-{lang}.html'
+DRAFT_LANG_URL = 'blog/draft/{slug}-{lang}'
 
 # The place where we will save an article draft which doesn’t use the default
 # language.
-DRAFT_LANG_SAVE_AS = 'draft/blog/{slug}-{lang}.html'
+DRAFT_LANG_SAVE_AS = 'blog/draft/{slug}-{lang}.html'
 
 # The URL we will use to link to a page.
-PAGE_URL = 'page/{slug}.html'
+PAGE_URL = 'page/{slug}'
 
 # The location we will save the page. This value has to be the same as PAGE_URL
 # or you need to use a rewrite in your server config.
 PAGE_SAVE_AS = 'page/{slug}.html'
 
 # The URL we will use to link to a page which doesn’t use the default language.
-PAGE_LANG_URL = 'page/{slug}-{lang}.html'
+PAGE_LANG_URL = 'page/{slug}-{lang}'
 
 #The location we will save the page which doesn’t use the default language.
 PAGE_LANG_SAVE_AS = 'page/{slug}-{lang}.html'
 
 # The URL used to link to a page draft.
-DRAFT_PAGE_URL = 'draft/page/{slug}.html'
+DRAFT_PAGE_URL = 'page/draft/{slug}'
 
 # The actual location a page draft is saved at.
-DRAFT_PAGE_SAVE_AS = 'draft/page/{slug}.html'
+DRAFT_PAGE_SAVE_AS = 'page/draft/{slug}.html'
 
 # The URL used to link to a page draft which doesn’t use the default language.
-DRAFT_PAGE_LANG_URL = 'draft/page/{slug}-{lang}.html'
+DRAFT_PAGE_LANG_URL = 'page/draft/{slug}-{lang}'
 
 # The actual location a page draft which doesn’t use the default language is
 # saved at.
 DRAFT_PAGE_LANG_SAVE_AS = 'draft/page/{slug}-{lang}.html'
 
 # The URL to use for a category.
-CATEGORY_URL = 'category/{slug}.html'
+CATEGORY_URL = 'category/{slug}'
 
 # The location to save a category.
 CATEGORY_SAVE_AS = 'category/{slug}.html'
 
 # The URL to use for a tag.
-TAG_URL = 'tag/{slug}.html'
+TAG_URL = 'tag/{slug}'
 
 # The location to save the tag page.
 TAG_SAVE_AS = 'tag/{slug}.html'
 
 # The URL to use for an author.
-AUTHOR_URL = 'author/{slug}.html'
+AUTHOR_URL = 'author/{slug}'
 
 # The location to save an author.
 AUTHOR_SAVE_AS = 'author/{slug}.html'
@@ -205,9 +213,21 @@
 # {url} placeholder in PAGINATION_PATTERNS.
 DAY_ARCHIVE_URL = ''
 
-# Gallery plugin
-PHOTO_LIBRARY = '/home/luigi/giggi.me.c/multimedia/gallery'
+# Photos plugin
+PHOTO_LIBRARY = os.getenv('PELICAN_PHOTO_LIBRARY')
+PHOTO_EXCLUDE = os.getenv('PELICAN_PHOTO_EXCLUDE')
+PHOTO_EXCLUDEALL = os.getenv('PELICAN_PHOTO_EXCLUDEALL')
+PHOTO_SKIPTAG = os.getenv('PELICAN_PHOTO_SKIPTAG')
 PHOTO_GALLERY = (2000, 1333, 100)
 PHOTO_ARTICLE = (2000, 1333, 100)
 PHOTO_THUMB = (300, 200, 100)
 PHOTO_SQUARE_THUMB = False
+PHOTO_RESIZE_JOBS = os.cpu_count()
+
+# Videos plugin
+VIDEO_LIBRARY = os.getenv('PELICAN_VIDEO_LIBRARY')
+VIDEO_EXCLUDE = os.getenv('PELICAN_VIDEO_EXCLUDE')
+VIDEO_EXCLUDEALL = os.getenv('PELICAN_VIDEO_EXCLUDEALL')
+VIDEO_SKIPTAG = os.getenv('PELICAN_VIDEO_SKIPTAG')
+VIDEO_GALLERY = (720, 400, 100)
+VIDEO_ARTICLE = (720, 400, 100)
diff --git a/theme/plugins/assets/__init__.py b/plugins/assets/__init__.py
similarity index 100%
rename from theme/plugins/assets/__init__.py
rename to plugins/assets/__init__.py
diff --git a/theme/plugins/assets/assets.py b/plugins/assets/assets.py
similarity index 100%
rename from theme/plugins/assets/assets.py
rename to plugins/assets/assets.py
diff --git a/theme/plugins/photos/README.md b/plugins/photos/README.md
similarity index 100%
rename from theme/plugins/photos/README.md
rename to plugins/photos/README.md
diff --git a/theme/plugins/photos/SourceCodePro-Bold.otf b/plugins/photos/SourceCodePro-Bold.otf
similarity index 100%
rename from theme/plugins/photos/SourceCodePro-Bold.otf
rename to plugins/photos/SourceCodePro-Bold.otf
Binary files differ
diff --git a/theme/plugins/photos/SourceCodePro-Regular.otf b/plugins/photos/SourceCodePro-Regular.otf
similarity index 100%
rename from theme/plugins/photos/SourceCodePro-Regular.otf
rename to plugins/photos/SourceCodePro-Regular.otf
Binary files differ
diff --git a/theme/plugins/photos/__init__.py b/plugins/photos/__init__.py
similarity index 100%
rename from theme/plugins/photos/__init__.py
rename to plugins/photos/__init__.py
diff --git a/theme/plugins/photos/licenses.json b/plugins/photos/licenses.json
similarity index 100%
rename from theme/plugins/photos/licenses.json
rename to plugins/photos/licenses.json
diff --git a/theme/plugins/photos/photos.py b/plugins/photos/photos.py
similarity index 74%
rename from theme/plugins/photos/photos.py
rename to plugins/photos/photos.py
index d0c1af1..1076cbd 100644
--- a/theme/plugins/photos/photos.py
+++ b/plugins/photos/photos.py
@@ -62,6 +62,9 @@
     DEFAULT_CONFIG.setdefault('PHOTO_EXIF_COPYRIGHT_AUTHOR', DEFAULT_CONFIG['SITENAME'])
     DEFAULT_CONFIG.setdefault('PHOTO_LIGHTBOX_GALLERY_ATTR', 'data-lightbox')
     DEFAULT_CONFIG.setdefault('PHOTO_LIGHTBOX_CAPTION_ATTR', 'data-title')
+    DEFAULT_CONFIG.setdefault('PHOTO_EXCLUDE', [])
+    DEFAULT_CONFIG.setdefault('PHOTO_EXCLUDEALL', False)
+    DEFAULT_CONFIG.setdefault('PHOTO_SKIPTAG', '')
 
     DEFAULT_CONFIG['queue_resize'] = {}
     DEFAULT_CONFIG['created_galleries'] = {}
@@ -89,7 +92,13 @@
         pelican.settings.setdefault('PHOTO_EXIF_COPYRIGHT_AUTHOR', pelican.settings['AUTHOR'])
         pelican.settings.setdefault('PHOTO_LIGHTBOX_GALLERY_ATTR', 'data-lightbox')
         pelican.settings.setdefault('PHOTO_LIGHTBOX_CAPTION_ATTR', 'data-title')
+        # Need to change type, PHOTO_EXCLUDE must be iterable
+        if pelican.settings['PHOTO_EXCLUDE'] is None:
+            pelican.settings['PHOTO_EXCLUDE'] = []
+        pelican.settings['PHOTO_EXCLUDEALL'] = pelican.settings['PHOTO_EXCLUDEALL'] == '1'
 
+        if pelican.settings['PHOTO_SKIPTAG'] is None:
+            pelican.settings.setdefault('PHOTO_SKIPTAG', '')
 
 def read_notes(filename, msg=None):
     notes = {}
@@ -117,7 +126,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):
@@ -249,6 +260,9 @@
     return (img, piexif.dump(exif))
 
 
+# Define a global lock as 'apply_async' doesn't support sharing primitives
+GLock = multiprocessing.Lock()
+
 def resize_worker(orig, resized, spec, settings):
     logger.info('photos: make photo {} -> {}'.format(orig, resized))
     im = Image.open(orig)
@@ -273,23 +287,54 @@
     if isalpha(im):
         im = remove_alpha(im, settings['PHOTO_ALPHA_BACKGROUND_COLOR'])
 
+    GLock.acquire()
     if not os.path.exists(directory):
         try:
             os.makedirs(directory)
         except Exception:
             logger.exception('Could not create {}'.format(directory))
     else:
-        logger.debug('Directory already exists at {}'.format(os.path.split(resized)[0]))
+        if not settings['PHOTO_SKIPTAG']:
+            logger.debug('Directory already exists at {}'.format(os.path.split(resized)[0]))
+    GLock.release()
 
     if settings['PHOTO_WATERMARK']:
         isthumb = True if spec == settings['PHOTO_THUMB'] else False
         if not isthumb or (isthumb and settings['PHOTO_WATERMARK_THUMB']):
             im = watermark_photo(im, settings)
 
-    im.save(resized, 'JPEG', quality=spec[2], icc_profile=icc_profile, exif=exif_copy)
+    try:
+        im.save(resized, 'JPEG', quality=spec[2], icc_profile=icc_profile, exif=exif_copy)
+
+        if settings['PHOTO_SKIPTAG']:
+            if not resized.endswith('.tmb.jpg'):
+                output_path = os.path.realpath(settings['OUTPUT_PATH'])
+                output_photos_path = os.path.join(output_path, 'photos')
+                cleaner_sh = os.path.join(output_photos_path, 'clean_skiptag.sh')
+                resized_relpath = os.path.relpath(resized, output_photos_path)
+                original_relpath = os.path.join(os.path.dirname(resized_relpath),
+                                                os.path.basename(orig))
+                try:
+                    GLock.acquire()
+                    with open(cleaner_sh, "a") as bash_script:
+                        bash_line = "[ ! -f '{}' ] || rm -f '{}'"
+                        print(bash_line.format(resized_relpath, original_relpath),
+                              file=bash_script)
+                    GLock.release()
+                except Exception as e:
+                    except_msg = 'photos: could not open file {}'
+                    logger.exception(except_msg.format(cleaner_sh))
+                    GLock.release()
+
+    except Exception as e:
+         logger.exception('photos: could not save image {}'.format(resized))
 
 
 def resize_photos(generator, writer):
+    if generator.settings['PHOTO_EXCLUDEALL']:
+        logger.warning('photos: Skip all galleries')
+        return
+
     if generator.settings['PHOTO_RESIZE_JOBS'] == -1:
         debug = True
         generator.settings['PHOTO_RESIZE_JOBS'] = 1
@@ -300,6 +345,13 @@
     logger.debug('Debug Status: {}'.format(debug))
     for resized, what in DEFAULT_CONFIG['queue_resize'].items():
         resized = os.path.join(generator.output_path, resized)
+        abs_path = os.path.split(resized)[0]
+        basename = os.path.basename(abs_path)
+
+        if basename in generator.settings['PHOTO_EXCLUDE']:
+            logger.warning('photos: skip gallery: {}'.format(basename))
+            continue
+
         orig, spec = what
         if (not os.path.isfile(resized) or os.path.getmtime(orig) > os.path.getmtime(resized)):
             if debug:
@@ -330,15 +382,32 @@
             )
 
             if os.path.isfile(path):
+                original_filename = os.path.basename(path)
+                if original_filename.endswith('.tmb.jpg'):
+                    return output
+
+                do_enqueue = True
+                do_rename = False
+                if settings['PHOTO_SKIPTAG']:
+                    if original_filename.startswith(settings['PHOTO_SKIPTAG']):
+                        debug_msg = 'photos: flagged to skip: {}'
+                        logger.debug(debug_msg.format(original_filename))
+                        do_enqueue = False
+                    else:
+                        do_rename = True
+
                 photo_prefix = os.path.splitext(value)[0].lower()
+                if do_rename:
+                    photo_prefix = settings['PHOTO_SKIPTAG'] + photo_prefix
 
                 if what == 'photo':
-                    photo_article = photo_prefix + 'a.jpg'
-                    enqueue_resize(
-                        path,
-                        os.path.join('photos', photo_article),
-                        settings['PHOTO_ARTICLE']
-                    )
+                    photo_article = photo_prefix + '.art.jpg'
+                    if do_enqueue:
+                        enqueue_resize(
+                            path,
+                            os.path.join('photos', photo_article),
+                            settings['PHOTO_ARTICLE']
+                        )
 
                     output = ''.join((
                         '<',
@@ -354,18 +423,19 @@
 
                 elif what == 'lightbox' and tag == 'img':
                     photo_gallery = photo_prefix + '.jpg'
-                    enqueue_resize(
-                        path,
-                        os.path.join('photos', photo_gallery),
-                        settings['PHOTO_GALLERY']
-                    )
+                    if do_enqueue:
+                        enqueue_resize(
+                            path,
+                            os.path.join('photos', photo_gallery),
+                            settings['PHOTO_GALLERY']
+                        )
 
-                    photo_thumb = photo_prefix + 't.jpg'
-                    enqueue_resize(
-                        path,
-                        os.path.join('photos', photo_thumb),
-                        settings['PHOTO_THUMB']
-                    )
+                        photo_thumb = photo_prefix + '.tbm.jpg'
+                        enqueue_resize(
+                            path,
+                            os.path.join('photos', photo_thumb),
+                            settings['PHOTO_THUMB']
+                        )
 
                     lightbox_attr_list = ['']
 
@@ -473,22 +543,57 @@
             logger.info('photos: Gallery detected: {}'.format(rel_gallery))
             dir_photo = os.path.join('photos', rel_gallery.lower())
             dir_thumb = os.path.join('photos', rel_gallery.lower())
-            exifs = read_notes(os.path.join(dir_gallery, 'exif.txt'),
-                               msg='photos: No EXIF for gallery')
-            captions = read_notes(os.path.join(dir_gallery, 'captions.txt'), msg='photos: No captions for gallery')
-            blacklist = read_notes(os.path.join(dir_gallery, 'blacklist.txt'), msg='photos: No blacklist for gallery')
-            content_gallery = []
 
+            try:
+                exifs = read_notes(os.path.join(dir_gallery, 'exif.txt'),
+                                   msg='photos: No EXIF for gallery')
+                captions = read_notes(os.path.join(dir_gallery, 'captions.txt'),
+                                      msg='photos: No captions for gallery')
+                blacklist = read_notes(os.path.join(dir_gallery, 'blacklist.txt'),
+                                       msg='photos: No blacklist for gallery')
+            except Exception as e:
+                logger.debug('photos: exception {}'.format(pprint.pformat(e)))
+
+            content_gallery = []
             title = gallery['title']
+
             for pic in sorted(os.listdir(dir_gallery)):
                 if pic.startswith('.'):
                     continue
                 if pic.endswith('.txt'):
                     continue
+                if pic.endswith('.tmb.jpg'):
+                    continue
                 if pic in blacklist:
                     continue
+
+                do_enqueue = True
+                do_rename = False
+                if generator.settings['PHOTO_SKIPTAG']:
+                    if pic.startswith(generator.settings['PHOTO_SKIPTAG']):
+                        debug_msg = 'photos: flagged to skip: {}'
+                        logger.debug(debug_msg.format(pic))
+                        do_enqueue = False
+                    else:
+                        do_rename = True
+
                 photo = os.path.splitext(pic)[0].lower() + '.jpg'
-                thumb = os.path.splitext(pic)[0].lower() + 't.jpg'
+                thumb = os.path.splitext(pic)[0].lower() + '.tmb.jpg'
+
+                if do_rename:
+                    photo = generator.settings['PHOTO_SKIPTAG'] + photo
+                    thumb = generator.settings['PHOTO_SKIPTAG'] + thumb
+
+                if do_enqueue:
+                    enqueue_resize(
+                        os.path.join(dir_gallery, pic),
+                        os.path.join(dir_photo, photo),
+                        generator.settings['PHOTO_GALLERY'])
+                    enqueue_resize(
+                        os.path.join(dir_gallery, pic),
+                        os.path.join(dir_thumb, thumb),
+                        generator.settings['PHOTO_THUMB'])
+
                 content_gallery.append((
                     pic,
                     os.path.join(dir_photo, photo),
@@ -496,25 +601,16 @@
                     exifs.get(pic, ''),
                     captions.get(pic, '')))
 
-                enqueue_resize(
-                    os.path.join(dir_gallery, pic),
-                    os.path.join(dir_photo, photo),
-                    generator.settings['PHOTO_GALLERY'])
-                enqueue_resize(
-                    os.path.join(dir_gallery, pic),
-                    os.path.join(dir_thumb, thumb),
-                    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:
@@ -538,20 +634,41 @@
         image = file_clipper(image)
 
     if os.path.isfile(path):
-        photo = os.path.splitext(image)[0].lower() + 'a.jpg'
-        thumb = os.path.splitext(image)[0].lower() + 't.jpg'
+        original_filename = os.path.basename(path)
+        if original_filename.endswith('.tmb.jpg'):
+            return
+
+        do_enqueue = True
+        do_rename = False
+        if generator.settings['PHOTO_SKIPTAG']:
+            if original_filename.startswith(generator.settings['PHOTO_SKIPTAG']):
+                debug_msg = 'photos: flagged to skip: {}'
+                logger.debug(debug_msg.format(original_filename))
+                do_enqueue = False
+            else:
+                do_rename = True
+
+        photo = os.path.splitext(image)[0].lower() + '.art.jpg'
+        thumb = os.path.splitext(image)[0].lower() + '.tmb.jpg'
+
+        if do_rename:
+            photo = generator.settings['PHOTO_SKIPTAG'] + photo
+            thumb = generator.settings['PHOTO_SKIPTAG'] + thumb
+
+        if do_enqueue:
+            enqueue_resize(
+                path,
+                os.path.join('photos', photo),
+                generator.settings['PHOTO_ARTICLE'])
+            enqueue_resize(
+                path,
+                os.path.join('photos', thumb),
+                generator.settings['PHOTO_THUMB'])
+
         content.photo_image = (
             os.path.basename(image).lower(),
             os.path.join('photos', photo),
             os.path.join('photos', thumb))
-        enqueue_resize(
-            path,
-            os.path.join('photos', photo),
-            generator.settings['PHOTO_ARTICLE'])
-        enqueue_resize(
-            path,
-            os.path.join('photos', thumb),
-            generator.settings['PHOTO_THUMB'])
     else:
         logger.error('photo: No photo for {} at {}'.format(content.source_path, path))
 
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..fea7d71
--- /dev/null
+++ b/plugins/videos/videos.py
@@ -0,0 +1,763 @@
+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.setdefault('VIDEO_SKIPTAG', '')
+
+    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
+
+        if pelican.settings['VIDEO_SKIPTAG'] is None:
+            pelican.settings.setdefault('VIDEO_SKIPTAG', '')
+
+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)
+
+# Define a global lock as 'apply_async' doesn't support sharing primitives
+GLock = multiprocessing.Lock()
+
+def convert_worker(orig, converted, spec, settings):
+    directory = os.path.split(converted)[0]
+
+    GLock.acquire()
+    if not os.path.exists(directory):
+        try:
+            os.makedirs(directory)
+        except Exception:
+            logger.exception('videos: could not create {}'.format(directory))
+    else:
+        if not settings['VIDEO_SKIPTAG']:
+            debug_msg = 'videos: directory already exists at {}'
+            logger.debug(debug_msg.format(os.path.split(converted)[0]))
+    GLock.release()
+
+    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')
+
+        if settings['VIDEO_SKIPTAG']:
+            output_path = os.path.realpath(settings['OUTPUT_PATH'])
+            output_videos_path = os.path.join(output_path, 'videos')
+            cleaner_sh = os.path.join(output_videos_path, 'clean_skiptag.sh')
+            converted_relpath = os.path.relpath(converted, output_videos_path)
+            original_relpath = os.path.join(os.path.dirname(converted_relpath),
+                                            os.path.basename(orig))
+            try:
+                GLock.acquire()
+                with open(cleaner_sh, "a") as bash_script:
+                    bash_line = "[ ! -f '{}' ] || rm -f '{}'"
+                    print(bash_line.format(converted_relpath, original_relpath),
+                          file=bash_script)
+                GLock.release()
+            except Exception as e:
+                except_msg = 'videos: could not open file {}'
+                logger.exception(except_msg.format(cleaner_sh))
+                GLock.release()
+
+    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, do_enqueue):
+    # 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']:
+        if do_enqueue:
+            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):
+                original_filename = os.path.basename(path)
+                do_enqueue = True
+                do_rename = False
+                if settings['VIDEO_SKIPTAG']:
+                    if original_filename.startswith(settings['VIDEO_SKIPTAG']):
+                        debug_msg = 'videos: flagged to skip: {}'
+                        logger.debug(debug_msg.format(original_filename))
+                        do_enqueue = False
+                    else:
+                        do_rename = True
+
+                video_prefix = os.path.splitext(value)[0].lower()
+                if do_rename:
+                    video_prefix = settings['VIDEO_SKIPTAG'] + video_prefix
+
+                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, do_enqueue)
+
+                    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
+
+                do_enqueue = True
+                do_rename = False
+                if generator.settings['VIDEO_SKIPTAG']:
+                    if old_video.startswith(generator.settings['VIDEO_SKIPTAG']):
+                        debug_msg = 'videos: flagged to skip: {}'
+                        logger.debug(debug_msg.format(old_video))
+                        do_enqueue = False
+                    else:
+                        do_rename = True
+
+                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
+                if do_rename:
+                    tag_name = generator.settings['VIDEO_SKIPTAG'] + tag_name
+
+                # 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, do_enqueue)
+
+                # 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):
+        original_filename = os.path.basename(path)
+
+        do_enqueue = True
+        do_rename = False
+        if generator.settings['VIDEO_SKIPTAG']:
+            if original_filename.startswith(generator.settings['VIDEO_SKIPTAG']):
+                debug_msg = 'videos: flagged to skip: {}'
+                logger.debug(debug_msg.format(original_filename))
+                do_enqueue = False
+            else:
+                do_rename = True
+
+        ext = generator.settings['VIDEO_FORMAT']
+        video = os.path.splitext(video_name)[0].lower() + 'a.' + ext
+        if do_rename:
+            video = generator.settings['VIDEO_SKIPTAG'] + video
+
+        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, do_enqueue)
+    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/requirements.txt b/requirements.txt
similarity index 100%
rename from theme/requirements.txt
rename to requirements.txt
diff --git a/theme/plugins/assets/__pycache__/__init__.cpython-35.pyc b/theme/plugins/assets/__pycache__/__init__.cpython-35.pyc
deleted file mode 100644
index 581a1c8..0000000
--- a/theme/plugins/assets/__pycache__/__init__.cpython-35.pyc
+++ /dev/null
Binary files differ
diff --git a/theme/plugins/assets/__pycache__/assets.cpython-35.pyc b/theme/plugins/assets/__pycache__/assets.cpython-35.pyc
deleted file mode 100644
index fde1266..0000000
--- a/theme/plugins/assets/__pycache__/assets.cpython-35.pyc
+++ /dev/null
Binary files differ
diff --git a/theme/static/css/style.scss b/theme/static/css/style.scss
index 21212cb..bb4eb3d 100644
--- a/theme/static/css/style.scss
+++ b/theme/static/css/style.scss
@@ -15,7 +15,7 @@
 $danger: red;
 
 $sans: -apple-system, ".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", sans-serif;
-$mono: monospace;
+$mono: "Source Code Pro", monospace;
 
 $border_color: #c2c2c2;
 $pag_label_size: 60px;
@@ -27,7 +27,7 @@
 $main_font_size: 1rem;
 $nav_font_size: 1rem;
 $subtitle_font_size: 0.9rem;
-$code_font_size: 1rem;
+$code_font_size: 12px;
 $article_meta_font_size: 0.8rem;
 $pagination_font_size: 0.9rem;
 $footer_font_size: 0.7rem;
@@ -60,6 +60,7 @@
   background-color: $border_color;
   height: 1px;
   border: none;
+  margin: 0em;
 }
 
 img {
@@ -159,7 +160,34 @@
     max-width: $content_size;
     margin: auto;
 
+    /* Input form */
+    fieldset {
+      border: 0px;
+    }
+
+    div.form_errors {
+        p {
+          color: red;
+          font-weight: bold;
+        }
+    }
+
+    div.submit_msg {
+        p {
+          color: green;
+          font-weight: bold;
+        }
+    }
+
     div.article_title {
+       h3 {
+         padding: 2px;
+         margin: 2px;
+       }
+       p {
+         padding: 2px;
+         margin: 2px;
+       }
     }
 
     div.article_text {
@@ -168,6 +196,10 @@
         text-decoration: underline;
       }
 
+      .field {
+        border: 0;
+      }
+
       @mixin codeformat {
         color: black;
         font-size: $code_font_size;
@@ -194,7 +226,7 @@
       }
 
       div.highlight pre {
-        display: inline-block;
+        display: block;
         padding-left: 10px;
         overflow-x: auto;
         border: 1px solid $code-border;
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">
diff --git a/theme/templates/base.html b/theme/templates/base.html
index b59631a..d71a6f6 100644
--- a/theme/templates/base.html
+++ b/theme/templates/base.html
@@ -16,7 +16,7 @@
   <script src="{{ THEMEURL }}/{{ ASSET_URL }}" type="text/javascript"></script>
   {% endassets%}
 
-  <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css" rel="stylesheet">
+  <link href="https://use.fontawesome.com/releases/v5.8.2/css/all.css" rel="stylesheet">
 
   {% if FEED_ALL_ATOM %}
   <link href="{{ FEED_DOMAIN }}/{{ FEED_ALL_ATOM }}" type="application/atom+xml" rel="alternate" title="{{ SITENAME }} Full Atom Feed" />
@@ -48,7 +48,7 @@
   {# Choosing a specific link icon #}
   {%-  if temp.startswith('bitcoin:') %}{% set class = 'fa-btc' %}
   {% elif temp.startswith('irc:') %}{% set class = 'fa-comments' %}
-  {% elif temp.startswith('mailto:') %}{% set class = 'fa-envelope' %}
+  {% elif temp.startswith('mailto:') %}{% set class = 'fa fa-envelope' %}
   {% elif temp.startswith('skype:') %}{% set class = 'fa-skype' -%}
 
   {%- elif temp.endswith('.pdf') or
@@ -56,42 +56,43 @@
            temp.endswith('.doc') or
            temp.endswith('.docx') %}{% set class = 'fa-file-text' -%}
 
-  {%- elif temp.startswith('500px.com') %}{% set class = 'fa-500px' %}
-  {% elif temp.startswith('behance.net') %}{% set class = 'fa-behance' %}
-  {% elif temp.startswith('bitbucket.org') %}{% set class = 'fa-bitbucket' %}
-  {% elif temp.startswith('codepen.io') %}{% set class = 'fa-codepen' %}
-  {% elif temp.startswith('delicious.com') %}{% set class = 'fa-delicious' %}
-  {% elif temp.startswith('deviantart.com') %}{% set class = 'fa-deviantart' %}
-  {% elif temp.startswith('facebook.com') %}{% set class = 'fa-facebook' %}
-  {% elif temp.startswith('flickr.com') %}{% set class = 'fa-flickr' %}
-  {% elif temp.startswith('foursquare.com') %}{% set class = 'fa-foursquare' %}
-  {% elif temp.startswith('github.com') %}{% set class = 'fa-github' %}
-  {% elif temp.startswith('gitlab.com') %}{% set class = 'fa-gitlab' %}
-  {% elif temp.startswith('instagram.com') %}{% set class = 'fa-instagram' %}
-  {% elif temp.startswith('last.fm') %}{% set class = 'fa-lastfm' %}
-  {% elif temp.startswith('linkedin.com') %}{% set class = 'fa-linkedin' %}
-  {% elif temp.startswith('news.ycombinator.com') %}{% set class = 'fa-hacker-news' %}
-  {% elif temp.startswith('pinterest.com') %}{% set class = 'fa-pinterest' %}
-  {% elif temp.startswith('plus.google.com') %}{% set class = 'fa-google-plus-official' %}
-  {% elif temp.startswith('reddit.com') %}{% set class = 'fa-reddit' %}
-  {% elif temp.startswith('snapchat.com') %}{% set class = 'fa-snapchat-ghost' %}
-  {% elif temp.startswith('spotify.com') %}{% set class = 'fa-spotify' %}
-  {% elif temp.startswith('stackoverflow.com') %}{% set class = 'fa-stack-overflow' %}
-  {% elif temp.startswith('steamcommunity.com') %}{% set class = 'fa-steam' %}
-  {% elif temp.startswith('soundcloud.com') %}{% set class = 'fa-soundcloud' %}
-  {% elif temp.startswith('twitch.tv') %}{% set class = 'fa-twitch' %}
-  {% elif temp.startswith('twitter.com') %}{% set class = 'fa-twitter' %}
-  {% elif temp.startswith('vimeo.com') %}{% set class = 'fa-vimeo-square' %}
-  {% elif temp.startswith('vine.co') %}{% set class = 'fa-vine' %}
-  {% elif temp.startswith('youtube.com') %}{% set class = 'fa-youtube' -%}
-  {% elif temp.startswith('keybase.io') %}{% set class = 'fa-key' -%}
+  {%- elif temp.startswith('500px.com') %}{% set class = 'fa fa-500px fa-lg' %}
+  {% elif temp.startswith('behance.net') %}{% set class = 'fa behance fa-lg' %}
+  {% elif temp.startswith('bitbucket.org') %}{% set class = 'fa bitbucket fa-lg' %}
+  {% elif temp.startswith('codepen.io') %}{% set class = 'fa codepen fa-lg' %}
+  {% elif temp.startswith('delicious.com') %}{% set class = 'fa delicious fa-lg' %}
+  {% elif temp.startswith('deviantart.com') %}{% set class = 'fa deviantart fa-lg' %}
+  {% elif temp.startswith('facebook.com') %}{% set class = 'fa facebook fa-lg' %}
+  {% elif temp.startswith('flickr.com') %}{% set class = 'fa flickr fa-lg' %}
+  {% elif temp.startswith('foursquare.com') %}{% set class = 'fa foursquare fa-lg' %}
+  {% elif temp.startswith('github.com') %}{% set class = 'fa github fa-lg' %}
+  {% elif temp.startswith('gitlab.com') %}{% set class = 'fa gitlab fa-lg' %}
+  {% elif temp.startswith('instagram.com') %}{% set class = 'fa instagram fa-lg' %}
+  {% elif temp.startswith('last.fm') %}{% set class = 'fa lastfm fa-lg' %}
+  {% elif temp.startswith('linkedin.com') %}{% set class = 'fa linkedin fa-lg' %}
+  {% elif temp.startswith('news.ycombinator.com') %}{% set class = 'fa hacker-news fa-lg' %}
+  {% elif temp.startswith('pinterest.com') %}{% set class = 'fa pinterest fa-lg' %}
+  {% elif temp.startswith('plus.google.com') %}{% set class = 'fa google-plus-official fa-lg' %}
+  {% elif temp.startswith('reddit.com') %}{% set class = 'fa reddit fa-lg' %}
+  {% elif temp.startswith('snapchat.com') %}{% set class = 'fa snapchat-ghost fa-lg' %}
+  {% elif temp.startswith('spotify.com') %}{% set class = 'fa spotify fa-lg' %}
+  {% elif temp.startswith('stackoverflow.com') %}{% set class = 'fa stack-overflow fa-lg' %}
+  {% elif temp.startswith('steamcommunity.com') %}{% set class = 'fa steam fa-lg' %}
+  {% elif temp.startswith('soundcloud.com') %}{% set class = 'fa soundcloud fa-lg' %}
+  {% elif temp.startswith('twitch.tv') %}{% set class = 'fa twitch fa-lg' %}
+  {% elif temp.startswith('twitter.com') %}{% set class = 'fa twitter fa-lg' %}
+  {% elif temp.startswith('vimeo.com') %}{% set class = 'fa vimeo-square fa-lg' %}
+  {% elif temp.startswith('vine.co') %}{% set class = 'fa vine fa-lg' %}
+  {% elif temp.startswith('youtube.com') %}{% set class = 'fa youtube fa-lg' -%}
+  {% elif temp.startswith('keybase.io') %}{% set class = 'fa key fa-lg' -%}
+  {% elif temp.endswith('/gitles') %}{% set class = 'fas fa-code-branch' %}
 
-  {%- elif '.stackexchange.com' in temp %}{% set class = 'fa-stack-exchange' %}
-  {% elif '.tumblr.com' in temp %}{% set class = 'fa-tumblr' %}
-  {% elif 'debian.org' in temp %}{% set class = 'fa fa-linux fa-lg' %}
+  {%- elif '.stackexchange.com' in temp %}{% set class = 'fa fa-stack-exchange fa-lg' %}
+  {% elif '.tumblr.com' in temp %}{% set class = 'fa fa-tumblr fa-lg' %}
+  {% elif 'debian.org' in temp %}{% set class = 'fab fa-linux' %}
   {% endif -%}
 
-  <i class="fa {{ class }} fa-lg"></i>
+<i class="{{ class }}" style="font-size:1.5em"></i>
 {%- endmacro -%}
 
 {%- macro display_link(name, link, text) -%}
@@ -133,26 +134,25 @@
     <header>
       {% block header %}
       <p id="header">
-      <a href="{{ SITEURL }}">Home</a>
-
       {% if DISPLAY_PAGES_ON_MENU %}
       {% for p in pages %}
       {% if p.url != "index.html" %}
-      &#124; <a href="{{ SITEURL }}/{{ p.url }}">{{ p.title }}</a>
+      <a href="{{ SITEURL }}/{{ p.url }}">{{ p.title }}</a>&nbsp;&#124;
       {% endif %}
       {% endfor %}
       {% endif %}
       {% if INDEX_SAVE_AS and INDEX_SAVE_AS != "index.html" %}
-      &#124; <a href="{{ SITEURL }}/{{ INDEX_LINK_AS }}">Blog</a>
+      <a href="{{ SITEURL }}/{{ INDEX_LINK_URL }}">Blog</a>&nbsp;&#124;
       {% endif %}
       {% if FEED_ALL_ATOM %}
-      &#124; <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_ATOM }}">Atom Feed</a>
+      <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_ATOM }}">Atom Feed</a>&nbsp;&#124;
       {% endif %}
       {% if FEED_ALL_RSS %}
-      &#124; <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_RSS }}">RSS Feed</a>
+      <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_RSS }}">RSS Feed</a>&nbsp;&#124;
       {% endif %}
       {% for title, link in MENUITEMS %}
-            &#124; <a href="{{ SITEURL }}/{{ link }}">{{ title }}</a>
+      <a href="{{ SITEURL }}/{{ link }}">{{ title }}</a>
+      {% if not loop.last %}&#124;{% endif %}
       {% endfor %}
       </p>
       {% endblock header %}
diff --git a/theme/templates/login.html b/theme/templates/login.html
deleted file mode 100644
index 1d5dba0..0000000
--- a/theme/templates/login.html
+++ /dev/null
@@ -1,43 +0,0 @@
-{% extends "base.html" %}
-{% block title %}{{ page.title|striptags|escape }} | {{ SITENAME }}{% endblock %}
-
-{% block subheader %}{% endblock %}
-
-{% block content %}
-
-<article>
-  <div class="article_title">
-    <h1><a href="{{ SITEURL }}/{{ page.url }}" class="nohover">{{ page.title }}</a></h1>
-  </div>
-  <div class="article_text">
-    {{ page.content }}
-  </div>
-  <div class="article_text">
-    <?php echo form_open('login/view', 'id = loginform'); ?>
-    <fieldset class="field">
-      <div class="form_errors"><?php echo form_error('username'); ?></div>
-      <input class="input" type="text" name="username" placeholder="Username" id="username">
-    </fieldset>
-
-    <fieldset class="field">
-      <div class="form_errors"><?php echo form_error('password'); ?></div>
-      <input class="input" type="password" name="password" placeholder="Password" id="password">
-    </fieldset>
-
-    <fieldset class="field">
-      <input class="button submit" type="submit" value="Login">
-    </fieldset>
-
-    <fieldset class="field">
-      <div class="form_errors"><?php echo form_error('submit_msg'); ?></div>
-      <div class="submit_msg"><?php echo form_message(); ?></div>
-      <input type="hidden" name="submit_msg" id="submit_msg">
-    </fieldset>
-    </form>
-  </div>
-</article>
-{% endblock %}
-
-{% block scripts %}
-{{ super() }}
-{% endblock scripts %}