Initial commit.

This commit is contained in:
Tony DiCola 2015-02-10 17:34:46 -08:00
parent 41a8747539
commit e095906241
8 changed files with 643 additions and 0 deletions

35
directory.py Normal file
View File

@ -0,0 +1,35 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
class DirectoryReader(object):
def __init__(self, config):
"""Create an instance of a file reader that just reads a single
directory on disk.
"""
self._load_config(config)
def _load_config(self, config):
self._path = config.get('directory', 'path')
def search_paths(self):
"""Return a list of paths to search for files."""
return [self._path]
def is_changed(self):
"""Return true if the file search paths have changed."""
# For now just return false and assume the path never changes. In the
# future it might be interesting to watch for file changes and return
# 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
def idle_message(self):
"""Return a message to display when idle and no files are found."""
return 'No files found in {0}'.format(self._path)
def create_file_reader(config):
"""Create new file reader based on reading a directory on disk."""
return DirectoryReader(config)

69
hello_video.py Normal file
View File

@ -0,0 +1,69 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import os
import subprocess
import time
class HelloVideoPlayer(object):
def __init__(self, config):
"""Create an instance of a video player that runs hello_video.bin in the
background.
"""
self._process = None
self._load_config(config)
def _load_config(self, config):
self._extensions = config.get('hello_video', 'extensions') \
.translate(None, ' \t\r\n.') \
.split(',')
def supported_extensions(self):
"""Return list of supported file extensions."""
return self._extensions
def play(self, movie, loop=False):
"""Play the provided movied file, optionally looping it repeatedly."""
self.stop(3) # Up to 3 second delay to let the old player stop.
# Assemble list of arguments.
args = ['hello_video.bin']
if loop:
args.append('--loop') # Add loop parameter if necessary.
args.append(movie) # Add movie file path.
# Run hello_video process and direct standard output to /dev/null.
self._process = subprocess.Popen(args,
stdout=open(os.devnull, 'wb'),
close_fds=True)
def is_playing(self):
"""Return true if the video player is running, false otherwise."""
if self._process is None:
return False
self._process.poll()
return self._process.returncode is None
def stop(self, block_timeout_sec=None):
"""Stop the video player. block_timeout_sec is how many seconds to
block waiting for the player to stop before moving on.
"""
# Stop the player if it's running.
if self._process is not None and self._process.returncode is None:
# process.kill() doesn't seem to work reliably if USB drive is
# removed, instead just run a kill -9 on it.
subprocess.call(['kill', '-9', str(self._process.pid)])
# If a blocking timeout was specified, wait up to that amount of time
# for the process to stop.
start = time.time()
while self._process is not None and self._process.returncode is None:
if (time.time() - start) >= block_timeout_sec:
break
time.sleep(0)
# Let the process be garbage collected.
self._process = None
def create_player(config):
"""Create new video player based on hello_video."""
return HelloVideoPlayer(config)

31
model.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
class Playlist(object):
"""Representation of a playlist of movies."""
def __init__(self, movies):
"""Create a playlist from the provided list of movies."""
self._movies = movies
self._index = None
def get_next(self):
"""Get the next movie in the playlist. Will loop to start of playlist
after reaching end.
"""
# Check if no movies are in the playlist and return nothing.
if len(self._movies) == 0:
return None
# Start at the first movie and increment through them in order.
if self._index is None:
self._index = 0
else:
self._index += 1
# Wrap around to the start after finishing.
if self._index >= len(self._movies):
self._index = 0
return self._movies[self._index]
def length(self):
"""Return the number of movies in the playlist."""
return len(self._movies)

74
omxplayer.py Normal file
View File

@ -0,0 +1,74 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import os
import subprocess
import time
class OMXPlayer(object):
def __init__(self, config):
"""Create an instance of a video player that runs omxplayer in the
background.
"""
self._process = None
self._load_config(config)
def _load_config(self, config):
self._extensions = config.get('omxplayer', 'extensions') \
.translate(None, ' \t\r\n.') \
.split(',')
self._extra_args = config.get('omxplayer', 'extra_args').split()
self._sound = config.get('omxplayer', 'sound').lower()
assert self._sound in ('hdmi', 'local', 'both'), 'Unknown omxplayer sound configuration value: {0} Expected hdmi, local, or both.'.format(self._sound)
def supported_extensions(self):
"""Return list of supported file extensions."""
return self._extensions
def play(self, movie, loop=False):
"""Play the provided movied file, optionally looping it repeatedly."""
self.stop(3) # Up to 3 second delay to let the old player stop.
# Assemble list of arguments.
args = ['omxplayer']
args.extend(['-o', self._sound]) # Add sound arguments.
args.extend(self._extra_args) # Add extra arguments from config.
if loop:
args.append('--loop') # Add loop parameter if necessary.
args.append(movie) # Add movie file path.
# Run omxplayer process and direct standard output to /dev/null.
self._process = subprocess.Popen(args,
stdout=open(os.devnull, 'wb'),
close_fds=True)
def is_playing(self):
"""Return true if the video player is running, false otherwise."""
if self._process is None:
return False
self._process.poll()
return self._process.returncode is None
def stop(self, block_timeout_sec=None):
"""Stop the video player. block_timeout_sec is how many seconds to
block waiting for the player to stop before moving on.
"""
# Stop the player if it's running.
if self._process is not None and self._process.returncode is None:
# process.kill() doesn't seem to work reliably if USB drive is
# removed, instead just run a kill -9 on it.
subprocess.call(['kill', '-9', str(self._process.pid)])
# If a blocking timeout was specified, wait up to that amount of time
# for the process to stop.
start = time.time()
while self._process is not None and self._process.returncode is None:
if (time.time() - start) >= block_timeout_sec:
break
time.sleep(0)
# Let the process be garbage collected.
self._process = None
def create_player(config):
"""Create new video player based on omxplayer."""
return OMXPlayer(config)

46
usb_drive.py Normal file
View File

@ -0,0 +1,46 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import glob
from usb_drive_mounter import USBDriveMounter
class USBDriveReader(object):
def __init__(self, config):
"""Create an instance of a file reader that uses the USB drive mounter
service to keep track of attached USB drives and automatically mount
them for reading videos.
"""
self._load_config(config)
self._mounter = USBDriveMounter(root=self._mount_path,
readonly=self._readonly)
self._mounter.start_monitor()
def _load_config(self, config):
self._mount_path = config.get('usb_drive', 'mount_path')
self._readonly = config.getboolean('usb_drive', 'readonly')
def search_paths(self):
"""Return a list of paths to search for files. Will return a list of all
mounted USB drives.
"""
self._mounter.mount_all()
return glob.glob(self._mount_path + '*')
def is_changed(self):
"""Return true if the file search paths have changed, like when a new
USB drive is inserted.
"""
return self._mounter.poll_changes()
def idle_message(self):
"""Return a message to display when idle and no files are found."""
return 'Insert USB drive with compatible movies.'
def create_file_reader(config):
"""Create new file reader based on mounting USB drives."""
return USBDriveReader(config)

80
usb_drive_mounter.py Normal file
View File

@ -0,0 +1,80 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import glob
import subprocess
import time
import pyudev
class USBDriveMounter(object):
"""Service for automatically mounting attached USB drives."""
def __init__(self, root='/mnt/usbdrive', readonly=False):
"""Create an instance of the USB drive mounter service. Root is an
optional parameter which specifies the location and file name prefix for
mounted drives (a number will be appended to each mounted drive file
name). Readonly is a boolean that indicates if the drives should be
mounted as read-only or not (default false, writable).
"""
self._root = root
self._readonly = readonly
self._context = pyudev.Context()
def remove_all(self):
"""Unmount and remove mount points for all mounted drives."""
for path in glob.glob(self._root + '*'):
subprocess.call(['umount', '-l', path])
subprocess.call(['rm', '-r', path])
def mount_all(self):
"""Mount all attached USB drives. Readonly is a boolean that specifies
if the drives should be mounted read only (defaults to false).
"""
self.remove_all()
# Enumerate USB drive partitions by path like /dev/sda1, etc.
nodes = [x.device_node for x in self._context.list_devices(subsystem='block',
DEVTYPE='partition') \
if 'ID_BUS' in x and x['ID_BUS'] == 'usb']
# Mount each drive under the mount root.
for i, node in enumerate(nodes):
path = self._root + str(i)
subprocess.call(['mkdir', path])
args = ['mount']
if self._readonly:
args.append('-r')
args.extend([node, path])
subprocess.check_call(args)
def start_monitor(self):
"""Initialize monitoring of USB drive changes."""
self._monitor = pyudev.Monitor.from_netlink(self._context)
self._monitor.filter_by('block', 'partition')
self._monitor.start()
def poll_changes(self):
"""Check for changes to USB drives. Returns true if there was a USB
drive change, otherwise false.
"""
# Look for a drive change.
device = self._monitor.poll(0)
# If a USB drive changed (added/remove) remount all drives.
if device is not None and device['ID_BUS'] == 'usb':
return True
# Else nothing changed.
return False
if __name__ == '__main__':
# Run as a service that mounts all USB drives as read-only under the default
# path of /mnt/usbdrive*.
drive_mounter = USBDriveMounter(readonly=True)
drive_mounter.mount_all()
drive_mounter.start_monitor()
print 'Listening for USB drive changes (press Ctrl-C to quite)...'
while True:
if drive_mounter.poll_changes():
print 'USB drives changed!'
drive_mounter.mount_all()
time.sleep(0)

93
video_looper.ini Normal file
View File

@ -0,0 +1,93 @@
# Main configuration file for video looper.
# You can change settings like what video player is used or where to search for
# movie files. Lines that begin with # are comments that will be ignored.
# Uncomment a line by removing its preceding # character.
# Video looper configuration block follows.
[video_looper]
# What video player will be used to play movies. Can be either omxplayer or
# hello_video. omxplayer can play common formats like avi, mov, mp4, etc. and
# with full audio and video, but it has a small ~100ms delay between loops.
# hello_video is a simpler player that doesn't do audio and only plays raw H264
# streams, but loops seemlessly. The default is omxplayer.
video_player = omxplayer
#video_player = hello_video
# Where to find movie files. Can be either usb_drive or directory. When using
# usb_drive any USB stick inserted in to the Pi will be automatically mounted
# and searched for video files (only in the root directory). Alternatively the
# 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
# The rest of the configuration for video looper below is optional and can be
# ignored.
# Control whether informative messages about the current player state are
# displayed, like the number of movies loaded or if it's waiting to load movies.
# Default is true to display these messages, but can be set to false to disable
# them entirely.
osd = true
#osd = false
# Change the color of the background that is displayed behind movies (only works
# with omxplayer). Provide 3 numeric values from 0 to 255 separated by a commma
# for the red, green, and blue color value. Default is 0, 0, 0 or black.
bgcolor = 0, 0, 0
# Change the color of the foreground text that is displayed with the on screen
# display messages. Provide 3 numeric values in the same format as bgcolor
# above. Default is 255, 255, 255 or white.
fgcolor = 255, 255, 255
# Enable some output to standard output with program state. Good for debugging
# but otherwise should be disabled.
console_output = false
#console_output = true
# Directory file reader configuration follows.
[directory]
# The path to search for movies when using the directory file reader.
path = /home/pi
# USB drive file reader configuration follows.
[usb_drive]
# The path to mount new USB drives. A number will be appended to the path for
# each mounted drive (i.e. /mnt/usbdrive0, /mnt/usbdrive1, etc.).
mount_path = /mnt/usbdrive
# Whether to mount the USB drives as readonly (true) or writable (false). It is
# recommended to mount USB drives readonly for reliability.
readonly = true
# omxplayer configuration follows.
[omxplayer]
# List of supported file extensions. Must be comma separated and should not
# include the dot at the start of the extension.
extensions = avi, mov, mkv, mp4, m4v
# Sound output for omxplayer, either hdmi, local, or both. When set to hdmi the
# video sound will be played on the HDMI output, and when set to local the sound
# will be played on the analog audio output. A value of both will play sound on
# both HDMI and the analog output. The both value is the default.
sound = both
#sound = hdmi
#sound = local
# Any extra command line arguments to pass to omxplayer. It is not recommended
# that you change this unless you have a specific need to do so! The audio and
# video FIFO buffers are kept low to reduce clipping ends of movie at loop.
extra_args = --no-osd --audio_fifo 0.01 --video_fifo 0.01
# hello_video player configuration follows.
[hello_video]
# List of supported file extensions. Must be comma separated and should not
# include the dot at the start of the extension.
extensions = h264

215
video_looper.py Normal file
View File

@ -0,0 +1,215 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import atexit
import ConfigParser
import os
import re
import sys
import time
import pygame
from model import Playlist
# Basic video looper architecure:
#
# - VideoLooper class contains all the main logic for running the looper program.
#
# - Almost all state is configured in a .ini config file which is required for
# loading and using the VideoLooper class.
#
# - VideoLooper has loose coupling with file reader and video player classes that
# are used to find movie files and play videos respectively. The configuration
# defines which file reader and video player module will be loaded.
#
# - A file reader module needs to define at top level create_file_reader function
# that takes as a parameter a ConfigParser config object. The function should
# return an instance of a file reader class. See usb_drive.py and directory.py
# for the two provided file readers and their public interface.
#
# - Similarly a video player modules needs to define a top level create_player
# function that takes in configuration. See omxplayer.py and hello_video.py
# for the two provided video players and their public interface.
#
# - Future file readers and video players can be provided and referenced in the
# config to extend the video player use to read from different file sources
# or use different video players.
class VideoLooper(object):
def __init__(self, config_path):
"""Create an instance of the main video looper application class. Must
pass path to a valid video looper ini configuration file.
"""
# Load the configuration.
self._config = ConfigParser.SafeConfigParser()
if len(self._config.read(config_path)) == 0:
raise RuntimeError('Failed to find configuration file at {0}, is the application properly installed?'.format(config_path))
self._console_output = self._config.getboolean('video_looper', 'console_output')
# Load configured video player and file reader modules.
self._player = self._load_player()
atexit.register(self._player.stop) # Make sure to stop player on exit.
self._reader = self._load_file_reader()
# Load other configuration values.
self._osd = self._config.getboolean('video_looper', 'osd')
# Parse string of 3 comma separated values like "255, 255, 255" into
# list of ints for colors.
self._bgcolor = map(int, self._config.get('video_looper', 'bgcolor') \
.translate(None, ',') \
.split())
self._fgcolor = map(int, self._config.get('video_looper', 'fgcolor') \
.translate(None, ',') \
.split())
# Initialize pygame and display a blank screen.
pygame.display.init()
pygame.font.init()
pygame.mouse.set_visible(False)
size = (pygame.display.Info().current_w, pygame.display.Info().current_h)
self._screen = pygame.display.set_mode(size, pygame.FULLSCREEN)
self._blank_screen()
# Set other static internal state.
self._extensions = self._player.supported_extensions()
self._small_font = pygame.font.Font(None, 50)
self._big_font = pygame.font.Font(None, 250)
def _print(self, message):
"""Print message to standard output if console output is enabled."""
if self._console_output:
print(message)
def _load_player(self):
"""Load the configured video player and return an instance of it."""
module = self._config.get('video_looper', 'video_player')
return __import__(module).create_player(self._config)
def _load_file_reader(self):
"""Load the configured file reader and return an instance of it."""
module = self._config.get('video_looper', 'file_reader')
return __import__(module).create_file_reader(self._config)
def _build_playlist(self):
"""Search all the file reader paths for movie files with the provided
extensions.
"""
# Get list of paths to search from the file reader.
paths = self._reader.search_paths()
# Enumerate all movie files inside those paths.
movies = []
for ex in self._extensions:
for path in paths:
# Skip paths that don't exist or are files.
if not os.path.exists(path) or not os.path.isdir(path):
continue
movies.extend(['{0}/{1}'.format(path.rstrip('/'), x) \
for x in os.listdir(path) \
if re.search('\.{0}$'.format(ex), x,
flags=re.IGNORECASE)])
# Create a playlist with the sorted list of movies.
return Playlist(sorted(movies))
def _blank_screen(self):
"""Render a blank screen filled with the background color."""
self._screen.fill(self._bgcolor)
pygame.display.update()
def _render_text(self, message, font=None):
"""Draw the provided message and return as pygame surface of it rendered
with the configured foreground and background color.
"""
# Default to small font if not provided.
if font is None:
font = self._small_font
return font.render(message, True, self._fgcolor, self._bgcolor)
def _animate_countdown(self, playlist, seconds=10):
"""Print text with the number of loaded movies and a quick countdown
message if the on screen display is enabled.
"""
# Print message to console with number of movies in playlist.
message = 'Found {0} movie{1}.'.format(playlist.length(),
's' if playlist.length() >= 2 else '')
self._print(message)
# Do nothing else if the OSD is turned off.
if not self._osd:
return
# Draw message with number of movies loaded and animate countdown.
# First render text that doesn't change and get static dimensions.
label1 = self._render_text(message + ' Starting playback in:')
l1w, l1h = label1.get_size()
sw, sh = self._screen.get_size()
for i in range(seconds, 0, -1):
# Each iteration of the countdown rendering changing text.
label2 = self._render_text(str(i), self._big_font)
l2w, l2h = label2.get_size()
# Clear screen and draw text with line1 above line2 and all
# centered horizontally and vertically.
self._screen.fill(self._bgcolor)
self._screen.blit(label1, (sw/2-l1w/2, sh/2-l2h/2-l1h))
self._screen.blit(label2, (sw/2-l2w/2, sh/2-l2h/2))
pygame.display.update()
# Pause for a second between each frame.
time.sleep(1)
def _idle_message(self):
"""Print idle message from file reader."""
# Print message to console.
message = self._reader.idle_message()
self._print(message)
# Do nothing else if the OSD is turned off.
if not self._osd:
return
# Display idle message in center of screen.
label = self._render_text(message)
lw, lh = label.get_size()
sw, sh = self._screen.get_size()
self._screen.fill(self._bgcolor)
self._screen.blit(label, (sw/2-lw/2, sh/2-lh/2))
pygame.display.update()
def _prepare_to_run_playlist(self, playlist):
"""Display messages when a new playlist is loaded."""
# If there are movies to play show a countdown first (if OSD enabled),
# or if no movies are available show the idle message.
if playlist.length() > 0:
self._animate_countdown(playlist)
self._blank_screen()
else:
self._idle_message()
def run(self):
"""Main program loop. Will never return!"""
# Get playlist of movies to play from file reader.
playlist = self._build_playlist()
self._prepare_to_run_playlist(playlist)
# Main loop to play videos in the playlist and listen for file changes.
while True:
# Load and play a new movie if nothing is playing.
if not self._player.is_playing():
movie = playlist.get_next()
if movie is not None:
# Start playing the first available movie.
self._print('Playing movie: {0}'.format(movie))
self._player.play(movie, loop=playlist.length() == 1)
# Check for changes in the file search path (like USB drives added)
# and rebuild the playlist.
if self._reader.is_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).
playlist = self._build_playlist()
self._prepare_to_run_playlist(playlist)
# Give the CPU some time to do other tasks.
time.sleep(0)
# Main entry point.
if __name__ == '__main__':
# Default config path to /boot.
config_path = '/boot/video_looper.ini'
# Override config path if provided as parameter.
if len(sys.argv) == 2:
config_path = sys.argv[1]
# Create video looper and run it.
videolooper = VideoLooper(config_path)
videolooper.run()