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" %}
- | <a href="{{ SITEURL }}/{{ p.url }}">{{ p.title }}</a>
+ <a href="{{ SITEURL }}/{{ p.url }}">{{ p.title }}</a> |
{% endif %}
{% endfor %}
{% endif %}
{% if INDEX_SAVE_AS and INDEX_SAVE_AS != "index.html" %}
- | <a href="{{ SITEURL }}/{{ INDEX_LINK_AS }}">Blog</a>
+ <a href="{{ SITEURL }}/{{ INDEX_LINK_URL }}">Blog</a> |
{% endif %}
{% if FEED_ALL_ATOM %}
- | <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_ATOM }}">Atom Feed</a>
+ <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_ATOM }}">Atom Feed</a> |
{% endif %}
{% if FEED_ALL_RSS %}
- | <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_RSS }}">RSS Feed</a>
+ <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_RSS }}">RSS Feed</a> |
{% endif %}
{% for title, link in MENUITEMS %}
- | <a href="{{ SITEURL }}/{{ link }}">{{ title }}</a>
+ <a href="{{ SITEURL }}/{{ link }}">{{ title }}</a>
+ {% if not loop.last %}|{% 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 %}