diff --git a/Adafruit_Video_Looper/directory.py b/Adafruit_Video_Looper/directory.py index eaca2dd..e67f145 100644 --- a/Adafruit_Video_Looper/directory.py +++ b/Adafruit_Video_Looper/directory.py @@ -1,6 +1,9 @@ # Copyright 2015 Adafruit Industries. # Author: Tony DiCola # License: GNU GPLv2, see LICENSE.txt + +import os.path + class DirectoryReader(object): def __init__(self, config): @@ -8,6 +11,7 @@ class DirectoryReader(object): directory on disk. """ self._load_config(config) + self._mtime = 0 def _load_config(self, config): self._path = config.get('directory', 'path') @@ -23,7 +27,10 @@ class DirectoryReader(object): # true if new files are added/removed from the directory. This is # called in a tight loop of the main program so it needs to be fast and # not resource intensive. - return False + mtime = os.path.getmtime(self._path) + changed = mtime and mtime > self._mtime + self._mtime = mtime + return changed def idle_message(self): """Return a message to display when idle and no files are found.""" diff --git a/Adafruit_Video_Looper/omxplayer.py b/Adafruit_Video_Looper/omxplayer.py index 83ea9a5..a52b690 100644 --- a/Adafruit_Video_Looper/omxplayer.py +++ b/Adafruit_Video_Looper/omxplayer.py @@ -38,6 +38,7 @@ class OMXPlayer(object): args.extend(['--vol', str(vol)]) if loop: args.append('--loop') # Add loop parameter if necessary. + args.append args.append(movie) # Add movie file path. # Run omxplayer process and direct standard output to /dev/null. self._process = subprocess.Popen(args, diff --git a/Adafruit_Video_Looper/templates/admin/index.html b/Adafruit_Video_Looper/templates/admin/index.html new file mode 100644 index 0000000..2677ef1 --- /dev/null +++ b/Adafruit_Video_Looper/templates/admin/index.html @@ -0,0 +1,20 @@ +{% extends 'admin/master.html' %} +{% block body %} +{{ super() }} +
+ +
+

Flask-Admin example

+

+ WebUI +

+ {% if not current_user.is_authenticated() %} +

+ login register +

+ {% endif %} +
+ + Back +
+{% endblock body %} diff --git a/Adafruit_Video_Looper/templates/index.html b/Adafruit_Video_Looper/templates/index.html new file mode 100644 index 0000000..09d17c6 --- /dev/null +++ b/Adafruit_Video_Looper/templates/index.html @@ -0,0 +1,7 @@ + + +
+ Go to admin! +
+ + diff --git a/Adafruit_Video_Looper/templates/my_master.html b/Adafruit_Video_Looper/templates/my_master.html new file mode 100644 index 0000000..5ed7b8c --- /dev/null +++ b/Adafruit_Video_Looper/templates/my_master.html @@ -0,0 +1,19 @@ +{% extends 'admin/base.html' %} + +{% block access_control %} +{% if current_user.is_authenticated() %} +
+ + + {% if current_user.first_name -%} + {{ current_user.first_name }} + {% else -%} + {{ current_user.email }} + {%- endif %} + + +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/Adafruit_Video_Looper/templates/security/_macros.html b/Adafruit_Video_Looper/templates/security/_macros.html new file mode 100644 index 0000000..9122f85 --- /dev/null +++ b/Adafruit_Video_Looper/templates/security/_macros.html @@ -0,0 +1,16 @@ +{% macro render_field_with_errors(field) %} +

+ {{ field.label }} {{ field(**kwargs)|safe }} + {% if field.errors %} +

+{% endif %} +

+{% endmacro %} + +{% macro render_field(field) %} +

{{ field(**kwargs)|safe }}

+{% endmacro %} \ No newline at end of file diff --git a/Adafruit_Video_Looper/templates/security/_menu.html b/Adafruit_Video_Looper/templates/security/_menu.html new file mode 100644 index 0000000..9e251b7 --- /dev/null +++ b/Adafruit_Video_Looper/templates/security/_menu.html @@ -0,0 +1,15 @@ +{% if security.registerable or security.recoverable or security.confirmable %} +

Menu

+ +{% endif %} diff --git a/Adafruit_Video_Looper/templates/security/_messages.html b/Adafruit_Video_Looper/templates/security/_messages.html new file mode 100644 index 0000000..1589a8b --- /dev/null +++ b/Adafruit_Video_Looper/templates/security/_messages.html @@ -0,0 +1,9 @@ +{%- with messages = get_flashed_messages(with_categories=true) -%} +{% if messages %} + +{% endif %} +{%- endwith %} \ No newline at end of file diff --git a/Adafruit_Video_Looper/templates/security/login_user.html b/Adafruit_Video_Looper/templates/security/login_user.html new file mode 100644 index 0000000..6f6620e --- /dev/null +++ b/Adafruit_Video_Looper/templates/security/login_user.html @@ -0,0 +1,18 @@ +{% extends 'admin/master.html' %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +{% block body %} +{{ super() }} +
+

Login

+
+ {{ login_user_form.hidden_tag() }} + {{ render_field_with_errors(login_user_form.email) }} + {{ render_field_with_errors(login_user_form.password) }} + {{ render_field_with_errors(login_user_form.remember) }} + {{ render_field(login_user_form.next) }} + {{ render_field(login_user_form.submit, class="btn btn-primary") }} +
+ {% include "security/_menu.html" %} +
+{% endblock body %} \ No newline at end of file diff --git a/Adafruit_Video_Looper/templates/security/register_user.html b/Adafruit_Video_Looper/templates/security/register_user.html new file mode 100644 index 0000000..b957f90 --- /dev/null +++ b/Adafruit_Video_Looper/templates/security/register_user.html @@ -0,0 +1,19 @@ +{% extends 'admin/master.html' %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +{% block body %} +{{ super() }} +
+

Register

+
+ {{ register_user_form.hidden_tag() }} + {{ render_field_with_errors(register_user_form.email) }} + {{ render_field_with_errors(register_user_form.password) }} + {% if register_user_form.password_confirm %} + {{ render_field_with_errors(register_user_form.password_confirm) }} + {% endif %} + {{ render_field(register_user_form.submit, class="btn btn-primary") }} +
+ {% include "security/_menu.html" %} +
+{% endblock body %} \ No newline at end of file diff --git a/Adafruit_Video_Looper/usb_drive.py b/Adafruit_Video_Looper/usb_drive.py index 64bb2a4..ab05f48 100644 --- a/Adafruit_Video_Looper/usb_drive.py +++ b/Adafruit_Video_Looper/usb_drive.py @@ -1,7 +1,6 @@ # Copyright 2015 Adafruit Industries. # Author: Tony DiCola # License: GNU GPLv2, see LICENSE.txt -import glob from usb_drive_mounter import USBDriveMounter @@ -28,7 +27,7 @@ class USBDriveReader(object): mounted USB drives. """ self._mounter.mount_all() - return glob.glob(self._mount_path + '*') + return [self._mount_path] def is_changed(self): """Return true if the file search paths have changed, like when a new diff --git a/Adafruit_Video_Looper/video_looper.py b/Adafruit_Video_Looper/video_looper.py index 6a40741..de1cb0f 100644 --- a/Adafruit_Video_Looper/video_looper.py +++ b/Adafruit_Video_Looper/video_looper.py @@ -8,6 +8,7 @@ import re import sys import signal import time +import glob import pygame @@ -116,21 +117,19 @@ class VideoLooper(object): # Skip paths that don't exist or are files. if not os.path.exists(path) or not os.path.isdir(path): continue - # Ignore hidden files (useful when file loaded on usb - # key from an OSX computer - movies.extend(['{0}/{1}'.format(path.rstrip('/'), x) \ - for x in os.listdir(path) \ - if re.search('\.{0}$'.format(ex), x, - flags=re.IGNORECASE) and \ - x[0] is not '.']) - # Get the video volume from the file in the usb key - sound_vol_file_path = '{0}/{1}'.format(path.rstrip('/'), self._sound_vol_file) - if os.path.exists(sound_vol_file_path): - with open(sound_vol_file_path, 'r') as sound_file: - sound_vol_string = sound_file.readline() - if self._is_number(sound_vol_string): - self._sound_vol = int(float(sound_vol_string)) - # Create a playlist with the sorted list of movies. + for root, dirnames, filenames in os.walk(path): + # Ignore hidden files (useful when file loaded on usb + # key from an OSX computer + for file in glob.glob('{}/[!.]*.{}'.format(root, ex)): + movies.append(os.path.join(root, file)) + # Get the video volume from the file in the usb key + sound_vol_file_path = '{0}/{1}'.format(root.rstrip('/'), self._sound_vol_file) + if os.path.exists(sound_vol_file_path): + with open(sound_vol_file_path, 'r') as sound_file: + sound_vol_string = sound_file.readline() + if self._is_number(sound_vol_string): + self._sound_vol = int(float(sound_vol_string)) + # Create a playlist with the sorted list of movies. return Playlist(sorted(movies), self._is_random) def _blank_screen(self): @@ -147,7 +146,7 @@ class VideoLooper(object): font = self._small_font return font.render(message, True, self._fgcolor, self._bgcolor) - def _animate_countdown(self, playlist, seconds=10): + def _animate_countdown(self, playlist, seconds=3): """Print text with the number of loaded movies and a quick countdown message if the on screen display is enabled. """ @@ -219,6 +218,7 @@ class VideoLooper(object): # Check for changes in the file search path (like USB drives added) # and rebuild the playlist. if self._reader.is_changed(): + self._print("CHANGED") self._player.stop(3) # Up to 3 second delay waiting for old # player to stop. # Rebuild playlist and show countdown again (if OSD enabled). diff --git a/Adafruit_Video_Looper/webui.py b/Adafruit_Video_Looper/webui.py new file mode 100644 index 0000000..bbb6d23 --- /dev/null +++ b/Adafruit_Video_Looper/webui.py @@ -0,0 +1,203 @@ +import os +import sys +import ConfigParser +import os.path as op +from flask import Flask, url_for, redirect, render_template, request, abort +from flask_sqlalchemy import SQLAlchemy +from flask_security import Security, SQLAlchemyUserDatastore, \ + UserMixin, RoleMixin, login_required, current_user +from flask_security.utils import encrypt_password +import flask_admin +from flask_admin.contrib import sqla +from flask_admin.contrib import fileadmin +from flask_admin import helpers as admin_helpers +from flask_ini import FlaskIni + + +# Create Flask application +app = Flask(__name__) +db = SQLAlchemy(app) + +config_path = '/boot/video_looper.ini' +if len(sys.argv) == 2: + config_path = sys.argv[1] +with app.app_context(): + app.iniconfig = FlaskIni() + app.iniconfig.read(config_path) +# Build a sample db on the fly, if one does not exist yet. +app_dir = os.path.realpath(os.path.dirname(__file__)) +database_path = os.path.join(app_dir, app.config['DATABASE_FILE']) +if not os.path.exists(database_path): + build_sample_db() + +print app.config["SECURITY_POST_LOGIN_VIEW"] +# Define models +roles_users = db.Table( + 'roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id')) +) + + +class Role(db.Model, RoleMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) + + def __repr__(self): + return self.name + +class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + active = db.Column(db.Boolean()) + roles = db.relationship('Role', secondary=roles_users, + backref=db.backref('users', lazy='dynamic')) + + +# Setup Flask-Security +user_datastore = SQLAlchemyUserDatastore(db, User, Role) +security = Security(app, user_datastore) + + +# Create customized model view class +class MyModelView(sqla.ModelView): + + def is_accessible(self): + if not current_user.is_active() or not current_user.is_authenticated(): + return False + + if current_user.has_role('superuser'): + return True + + return False + + def _handle_view(self, name, **kwargs): + """ + Override builtin _handle_view in order to redirect users when a view is not accessible. + """ + if not self.is_accessible(): + if current_user.is_authenticated(): + # permission denied + abort(403) + else: + # login + return redirect(url_for('security.login', next=request.url)) + +# Flask views +@app.route('/') +def index(): + return render_template('index.html') + +# Create admin +admin = flask_admin.Admin(app, 'Example: Auth', base_template='my_master.html') + +# Add model views +admin.add_view(MyModelView(Role, db.session)) +admin.add_view(MyModelView(User, db.session)) + +# define a context processor for merging flask-admin's template context into the +# flask-security views. +@security.context_processor +def security_context_processor(): + return dict( + admin_base_template=admin.base_template, + admin_view=admin.index_view, + h=admin_helpers, + ) + + +def build_sample_db(): + """ + Populate a small db with some example entries. + """ + + import string + import random + + db.drop_all() + db.create_all() + + with app.app_context(): + user_role = Role(name='user') + super_user_role = Role(name='superuser') + db.session.add(user_role) + db.session.add(super_user_role) + db.session.commit() + + test_user = user_datastore.create_user( + email='admin', + password=encrypt_password('admin'), + roles=[user_role, super_user_role] + ) + + emails = [ + 'rpi', + ] + + passwords = [ + 'rpi' + + ] + + for i in range(len(emails)): + #tmp_email = first_names[i].lower() + "." + last_names[i].lower() + "@example.com" + #tmp_pass = ''.join(random.choice(string.ascii_lowercase + string.digits) for i in range(10)) + user_datastore.create_user( + email=emails[i], + password=encrypt_password(passwords[i]), + roles=[user_role, ] + ) + db.session.commit() + return + + +class VideoAdmin(fileadmin.FileAdmin): + def __init__(self): + super(VideoAdmin, self).__init__(app.iniconfig.get("directory", "path"), + '/files/', name='Files') + + def get_base_path(self): + return self.user_path + + @property + def user_path(self): + if current_user.has_role('superuser'): + return self.base_dir + path = op.normpath('{}/{}'.format(app.iniconfig.get("directory", "path"), + current_user.email)) + if not op.exists(path): + os.makedirs(path) + return path + + @property + def base_dir(self): + return app.iniconfig.get("directory", "path") + + def is_accessible_path(self, path): + return True + + def update_base(self): + os.utime(self.base_dir, None) + + def on_file_upload(self, *args, **kwargs): + self.update_base() + + def on_edit_file(self, *args, **kwargs): + self.update_base() + + def on_file_delete(self, *args, **kwargs): + self.update_base() + + def on_rename(self, *args, **kwargs): + self.update_base() + + +videoadmin = VideoAdmin() +admin.add_view(videoadmin) + +if __name__ == '__main__': + app.run(host=app.config.get("HOST", "localhost"), + port=app.config.get("PORT", 5000), + debug=True) diff --git a/setup.py b/setup.py index a2fbaf2..c5e6bd3 100644 --- a/setup.py +++ b/setup.py @@ -3,11 +3,17 @@ use_setuptools() from setuptools import setup, find_packages setup(name = 'Adafruit_Video_Looper', - version = '1.0.0', + version = '1.1.0', author = 'Tony DiCola', author_email = 'tdicola@adafruit.com', description = 'Application to turn your Raspberry Pi into a dedicated looping video playback device, good for art installations, information displays, or just playing cat videos all day.', license = 'GNU GPLv2', url = 'https://github.com/adafruit/pi_video_looper', - install_requires = ['pyudev'], + install_requires = ['pyudev', + 'Flask', + 'Flask-Admin', + 'Flask-Ini', + 'Flask-SQLAlchemy', + 'Flask-Security', + ], packages = find_packages()) diff --git a/video_looper.conf b/video_looper.conf index b769d4e..acc6aa7 100644 --- a/video_looper.conf +++ b/video_looper.conf @@ -5,3 +5,9 @@ command=python -u -m Adafruit_Video_Looper.video_looper autostart=true autorestart=unexpected startsecs=5 + +[program:video_looper_webui] +command=python -u -m Adafruit_Video_Looper.webui +autostart=true +autorestart=unexpected +startsecs=5 diff --git a/video_looper.ini b/video_looper.ini index 9126237..9c16ad5 100644 --- a/video_looper.ini +++ b/video_looper.ini @@ -20,8 +20,8 @@ video_player = omxplayer # directory option will search only a specified directory on the SD card for # movie files. Note that you change the directory by modifying the setting in # the [directory] section below. The default is usb_drive. -file_reader = usb_drive -#file_reader = directory +#file_reader = usb_drive +file_reader = directory # The rest of the configuration for video looper below is optional and can be # ignored. @@ -54,7 +54,7 @@ console_output = true [directory] # The path to search for movies when using the directory file reader. -path = /home/pi +path = /home/pi/pi_video_looper/videos/ # USB drive file reader configuration follows. [usb_drive] @@ -98,3 +98,31 @@ extra_args = --no-osd --audio_fifo 0.01 --video_fifo 0.01 # List of supported file extensions. Must be comma separated and should not # include the dot at the start of the extension. extensions = h264 + +[flask] +host = 0.0.0.0 +# create dummy secrey key so we can use sessions +secret_key = 123456790 + +# create in-memory database +database_file = sample_db.sqlite +sqlalchemy_database_uri = sqlite:///sample_db.sqlite +sqlalchemy_echo = true + +# flask-security config +security_url_prefix = /admin +security_password_hash = pbkdf2_sha512 +security_password_salt = atguohaelkiubahiughaergojaegj + +# flask-security urls, overridden because they don't put a / at the end +security_login_url = /login/ +security_logout_url = /logout/ +security_register_url = /register/ + +security_post_login_view = /admin/ +security_post_logout_view = /admin/ +security_post_register_view = /admin/ + +# flask-security features +security_registerable = true +security_send_register_email = false