From e095906241c102ff3b31f35628ff615ee49019d9 Mon Sep 17 00:00:00 2001 From: Tony DiCola Date: Tue, 10 Feb 2015 17:34:46 -0800 Subject: [PATCH] Initial commit. --- directory.py | 35 +++++++ hello_video.py | 69 ++++++++++++++ model.py | 31 +++++++ omxplayer.py | 74 +++++++++++++++ usb_drive.py | 46 +++++++++ usb_drive_mounter.py | 80 ++++++++++++++++ video_looper.ini | 93 +++++++++++++++++++ video_looper.py | 215 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 643 insertions(+) create mode 100644 directory.py create mode 100644 hello_video.py create mode 100644 model.py create mode 100644 omxplayer.py create mode 100644 usb_drive.py create mode 100644 usb_drive_mounter.py create mode 100644 video_looper.ini create mode 100644 video_looper.py diff --git a/directory.py b/directory.py new file mode 100644 index 0000000..eaca2dd --- /dev/null +++ b/directory.py @@ -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) diff --git a/hello_video.py b/hello_video.py new file mode 100644 index 0000000..c35f6a7 --- /dev/null +++ b/hello_video.py @@ -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) diff --git a/model.py b/model.py new file mode 100644 index 0000000..56f2d7d --- /dev/null +++ b/model.py @@ -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) diff --git a/omxplayer.py b/omxplayer.py new file mode 100644 index 0000000..ae8ff0d --- /dev/null +++ b/omxplayer.py @@ -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) diff --git a/usb_drive.py b/usb_drive.py new file mode 100644 index 0000000..64bb2a4 --- /dev/null +++ b/usb_drive.py @@ -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) diff --git a/usb_drive_mounter.py b/usb_drive_mounter.py new file mode 100644 index 0000000..4ebfb8c --- /dev/null +++ b/usb_drive_mounter.py @@ -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) diff --git a/video_looper.ini b/video_looper.ini new file mode 100644 index 0000000..6bfb186 --- /dev/null +++ b/video_looper.ini @@ -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 diff --git a/video_looper.py b/video_looper.py new file mode 100644 index 0000000..92dca15 --- /dev/null +++ b/video_looper.py @@ -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()