Fernsehzeitmaschine (Linux-Magazin, Mai 2010)

Ein Perlskript mit Gtk2-Oberfläche merkt sich, wie weit gespeicherte Videos abgespult wurden und fährt auf Wunsch bei der letzten Unterbrechung fort.

Wer wie ich im Bus auf dem Weg zur Arbeit auf dem Netbook Fernsehfilme ansieht, muss unter Umständen an der spannendsten Stelle aussteigen und das Video anhalten. Bleiben dann beim nächsten Teilstück nur 15 Minuten, möchte man vielleicht nicht wieder ein weiteres Bruchstück des Filmes verfolgen, sondern lieber die ebenfalls heruntergeladene Tagesschau ([2]) abspielen. Um die angefangenen Sendungen später fertigzusehen, klickt man sie später einfach wieder auf der graphischen Oberfläche in Abbildung 1 an und das Programm fährt genau dort fort, wo der User vorher den Lauf abgebrochen hat.

Abbildung 1: Die Gtk2-Oberfläche startet auf Mausdruck ausgewählte Videos an der Stelle der letzten Unterbrechung.

Bloß kein Durcheinander

Eine Zeitmaschine für gespeicherte Videos also, genau so, wie das mein Tivo [3] seit mehr als zehn Jahren zuhause in San Francisco macht. Der digitale Videorekorder speichert eine Reihe von Fernsehsendungen und dazu jeweils den Zeitstempel der letzten Unterbrechung. Wählt man einen Film aus der Liste aus, nimmt der Tivo den Abspielvorgang genau dort wieder auf.

iTunes oder Podcast-Software machen es ähnlich. Wie schwierig wäre es wohl, ein kurzes Skript eigenhändig in Perl zu schreiben? Listing ttv zeigt mit weniger als 150 Zeilen das Ergebnis. Nun wäre es natürlich vermessen, ein Wunderwerk der Videotechnik wie mplayer nachzubauen. Außerdem bringt der Tausendsassa auch noch alle Voraussetzungen für eine Zeitmaschinensteuerung mit: Wie Abbildung 2 zeigt, zählt der laufende mplayer stetig die laufenden Videosekunden hoch, es ist also für ein Steuerprogramm relativ einfach, festzustellen, wie weit der Player mittlerweile mit dem Abspielen fortgeschritten ist. Das muss nicht immer linear geschehen, denn der User darf zum Beispiel mit den PageUp/PageDown-Tasten während des Laufs wild im Video hin- und herspringen. Als zweite Vorraussetzung für die Fernsteuerung durch ein externes Programm wie das vorgestellte Perlskript ist die Option -ss N, die den Player mit einer angefügten Sekundenzahl anweist, das Video nicht von Anfang an abzuspielen, sondern die ersten N Sekunden zu überspringen.

Abbildung 2: Mplayer zählt während des Laufs die Sekunden des Videos auf der Standardausgabe hoch. Das ttv-Skript greift die Daten von dort ab.

Damit ist klar, wie ttv funktioniert: Die Gtk2-getriebene Benutzeroberfläche wartet darauf, dass der User ein Video doppelklickt. Ist es das erste Mal, wirft die GUI den mplayer an und lässt ihn das Video von Anfang an abspielen. Während der Player läuft und der User den laufenden Film genießt, greift das Skript über Mplayers Standardausgabe die verstrichenen Videosekunden ab und speichert diese laufend zwischen. Bricht der User den Abspielvorgang ab (z.B. indem er im mplayer-Fenster die Taste ``q'' drückt), tritt die graphische Oberfläche wieder vor den User und das Skript legt die Abspieldauer unter dem Namen des Videos in der YAML-Datei ttv.dat unter dem Home-Verzeichnis ab (Abbildung 3).

Abbildung 3: In der YAML ~/.ttv.dat legt das Skript die Spieldauer tatsächlich angespielter Videos fest. Hier nicht gespeicherte Videos spielt es von Anfang an ab, falls der User sie auswählt.

Tanz auf fremden Hochzeiten

Erfahrene Snapshot-Leser ahnen schon, dass die Steuerung der GUI-Komponenten im ruckelfreien Zusammenspiel mit einem extern aufgerufenen Programm wie mplayer wieder einmal mit dem Event-basierten Perl-Framework POE vom CPAN realisiert wurde. Wie auch mit Terminalsteuerungen auf Curses-Basis hopst POE mit allen erdenklichen GUI-Eventschleifen im Takt und eignet sich hervorragend dazu, quasi-parallele Prozesse zu steuern. Im vorliegenden Fall startet das GUI-Skript den Videospieler hinter den Kulissen tatsächlich als separaten Prozess, aber das Abgreifen von dessen Ausgabedaten, das Anspringen von Callback-Handlern und die Kontrolle darüber, ob der Player überhaupt noch läuft oder vom User schon abgeschaltet wurde, verwaltet POE äußerst robust und elegant im Single-Process, Single-Thread-Verfahren.

Da Zeile 7 von ttv das Modul Gtk2 vor den POE-Modulen anfordert, ist POE darüber informiert, dass nicht seine eigene Eventschleife den Prozess steuern wird, sondern Gtk2 mit dem CPAN-Modul POE::Loop::Glib als unsichtbarer Brücke. Zeile 15 legt den Pfad der YAML-Datei fest, in der das Skript später den Hash-Inhalt der Referenz $OFFSETS speichert. Die Datenstruktur weist Videodateinamen jeweils einer Fließkommazahl zu, die die bereits abgespielten Sekunden angibt.

Die globale Variable $REWIND gibt an, dass das Skript jeweils 10 Sekunden zurückspult, bevor es wieder mitten in ein bereits zu einem früheren Zeitpunkt unterbrochenes Videos hineinspringt. Das gibt dem Zuschauer Gelegenheit, im Ablauf schnell wieder Fuß zu fassen. Verfügbare Videos sucht Zeile 19 im aktuellen Verzeichnis als .mp4 und .avi-Dateien zusammen und muss unter Umständen angepasst werden, falls der User andere Formate bevorzugt.

Ich kriege Zustände!

POE-typisch definiert der Session-Konstruktor ab Zeile 26 insgesamt fünf verschiedene Zustände, zwischen denen der im Skript definierte Automat hin- und herspringt. Nach dem Starten des POE-Kernels in Zeile 35 läuft die GUI und arbeitet User-Eingaben ab, bis der User das Programm mit einem Klick auf das Schließ-Icon des Hauptfensters beendet.

Per Definition legt der ``_start''-Zustand in Zeile 28 den Anfangszustand fest. Die ihm zugewiesene Funktion ui_start baut die graphische Oberfläche auf und ist ab Zeile 62 definiert.

Wie schon in früheren Beiträgen zum Thema POE ausgeführt (z.B. [4]), holen die Makros KERNEL, SESSION und HEAP Automatenvariablen aus Perls Array @_ für Funktionsargumente. HEAP ist ein Hash für eine ``Session'' des Automaten und dient zum Ablegen allerlei globaler Variablen, die der Automat dann von Callback zu Callback durchschleift, allerdings sauber von anderen Sessions getrennt.

Speichern oder Zusammenklappen

Zeile 68 ruft den Konstruktor des GUI-Hauptfensters Gtk2::Window auf, in dessen Rahmen später eine Listbox mit abspielbereiten Videos zu liegen kommt. Der Parameter 'toplevel' gibt an, dass es sich um das Hauptfenster der Applikation handelt. Eine Referenz darauf legt das Skript im HEAP ab, nicht, um später in Callbacks darauf zuzugreifen, sondern um sicher zu stellen, dass Perl eine Referenz auf das Hauptfenster in einer Variable speichert, die sich nicht mit Abschluss der Funktion ui_start in Luft auflöst. Geschähe dies nämlich, fiele das Applikationsfenster dann sang- und klanglos in sich zusammen, obwohl die Applikation noch weiterlaufen soll.

Der Aufruf der Methode signal_ui_destroy defininiert in Zeile 70, dass mit dem Zusammenfallen des Hauptfensters (zum Beispiel weil der User mit der Maus das Schließ-Icon angeklickt hat) sich auch die Applikation, also der POE-Kernel beendet. Das in Zeile 73 erzeugte Widget Gtk2::SimpleList speichert die Daten der zweispaltigen Anzeige. Wie Abbildung 1 zeigt, besteht jede Zeile der Video-Liste links aus einem Zeitstempel und rechts aus dem Namen einer Videodatei. Beide Spalten sind vom Typ ``text'', beherbergen also einfache Zeichenketten ohne farbliche Hervorhebungen oder anderen Schnickschnack. Unter dem Kürzel slist speichert das Skript eine Referenz auf das Widget im Session-Heap ab. Die Methode add() fügt die Listbox ins Hauptfenster ein und das nachfolgende show_all() zeichnet die GUI auf den Bildschirm.

Schwarze Magie

Die ab Zeile 90 definierte Funktion listbox_redraw() frischt die Listbox auf, indem sie ihr einfach unter dem Eintrag data neue Werte in einem Array von zweielementigen Arrays unterschiebt. Schwarze Magie im Widget (eine mit tie gebundene Datenstruktur) löst ohne weitere Maßnahmen dann sofort ein Neuzeichnen der graphischen Darstellung aus. Die Funktion timer() ab Zeile 100 bringt den Zeitstempel einer Videodatei, der in Sekunden vorliegt, im Format hh:mm:ss auf Vordermann.

Falls der User eine Listbox-Zeile mit der Maus doppelklickt, sorgt der Aufruf von signal_connect in Zeile 80 dafür, dass der POE-Zustandsautomat den Zustand ``click'' anspringt, und damit die ab Zeile 48 definierte Funktion click() aufruft. Als einziges Argument übergibt er ihr in ARG0 eine Referenz auf Listbox-Zustandsdaten, aus denen die Funktion get_row_data_from_path() diejenige Zeile hervorzaubert, auf die der User geklickt hat. Das zweite Element der zurückkommenden Arrayreferenz ist der Dateiname des gewünschten Videos. Der Aufruf yield() weist den POE-Kernel in Zeile 57 an, den Zustand ``play_video'' anzuspringen und ihm den Dateinamen des abzuspielenden Videos zu überreichen.

Dies startet die Funktion play_video ab Zeile 114, die zunächst herausfindet, ob in der globalen Variablen $OFFSETS ein Sekundenwert für das Video vorliegt und dann über das Modul POE::Wheel::Run den externen mplayer startet. Programm und Argumente nimmt das Wheel, ein Rädchen im Getriebe des POE-Kernels, getrennt als Program und ProgramArgs entgegen. Die Option -fs startet mplayer im Fullscreenmodus für vollen Videogenuss und -ss gibt die Anzahl der Sekunden vor, in die mplayer in das Video hineinspringt, bevor er mit dem Abspielen beginnt.

Kein Warten auf Umbrüche

Da der mplayer die Ausgabe der Videosekunden nicht durch Zeilenumbrüche trennt, greift der normale zeilenbasierte Filter von POE::Wheel::Run nicht und POE::Filter::Stream kommt in Zeile 131 zum Einsatz. Er wartet nicht, bis eine Zeile vollständig vorliegt, sondern lässt das Wheel den Ausgabezustand ``output'' anspringen, sobald ein neues Textschnipsel vorliegt.

Die in diesem Fall aufgerufene Funktion stdout_handler() ab Zeile 144 erhält so immer ein Schnipsel neu aufgeschnappter mplayer-Diagnoseausgabe und versucht mit dem regulären Ausdruck in Zeile 148, die in Abbildung 2 rot eingefärbten Videosekunden daraus zu extrahieren. Hierzu sucht es die Zeichenkette ``V:'' entweder am Zeilenanfang oder nach einem Leerzeichen und fängt eine nachfolgende Fließkommazahl in einer Capture-Klammer ein. Der gefundene Wert steht anschließend in der Variablen $1. Die erste Klammer im Regex dient nur der Gruppierung von regulären Ausdrücken und hat keine Capture-Funktion, was die Anweisung ``?:'' zum Ausdruck bringt.

Findet der Regex einen passenden Wert, legt stdout_handler() ihn unter dem Videonamen im globalen Hash ab, auf den die Referenz $OFFSETS zeigt. Diese Daten sichert das Skript jeweils am Ende des Abspielvorgangs in der YAML-Datei, wenn es den CloseEvent ``play_ended'' und damit die Funktion play_ended() ab Zeile 39 anspringt.

Die Kernel-Methode sig_child() in Zeile 137 weist den POE-Kernel an, den soeben gestarteten und später eventuell herumlungernden Fremdprozess mit dem mplayer abzuschießen, falls das Programm abbricht.

Patch für POE::Loop::Glib

Das CPAN-Modul POE::Loop::Glib wies zur Fertigstellung des Artikels noch einen Fehler in Version 0.037 auf, der die GUI nach einigen Sekunden Video abstürzen lässt. Falls beim Erscheinen des Beitrags die Version 0.038 auf dem CPAN verfügbar ist, hat der Modulautor meinen Patch hoffentlich eingespielt. Falls nicht, steht er unter den Listings auf dem Server des Linux-Magazins zum Download bereit. Folgende Befehlsfolge bringt die Moduldistribution nach dem Download des Tarballs von search.cpan.org auf den neuesten Stand:

    $ tar zxfv POE-Loop-Glib-0.037.tgz
    $ cd POE-Loop-Glib-0.037
    $ patch -p1 <../poe-loop-glib-0.037.patch
    patching file Changes
    patching file Makefile.PL
    patching file lib/POE/Loop/Glib.pm

und das übliche ``perl Makefile.PL; make; sudo make install'' installiert das gepatchte Modul im Perlbaum. Alle weiteren Module werden entweder mit einer CPAN-Shell installiert oder mit dem Package-Manager der verwendeten Linux-Distribution, falls diese die entsprechenden Module als Packages führt. Es ist darauf zu achten, dass das CPAN-Modul POE::Loop::Glib als unsichtbare Brücke ebenfalls installiert werden muss, auch wenn es nicht explizit im Listing erscheint.

Es bleibt anzumerken, dass man den angebotenen Funktionsumfang des Skripts nicht über Gebühr ausreizen sollte. Ich rate davon ab, mehr als drei Spielfilme gleichzeitig in Angriff zu nehmen, sonst kann es beim unaufmerksamen Zuschauer zu lustigen Verwirrungen führen, besonders wenn Matt Daemon und Leonardo DeCaprio in ähnlichen Kinowerken spielen.

Listing 1: ttv

    001 #!/usr/local/bin/perl -w
    002 use strict;
    003 use Gtk2 '-init';
    004 use Gtk2::SimpleList;
    005 use POE;
    006 use POE::Wheel::Run;
    007 use POE::Filter::Stream;
    008 use YAML qw(LoadFile DumpFile);
    009 
    010 my  ($home)    = glob "~";
    011 my  $YAML_FILE = "$home/.ttv.dat";
    012 my  $OFFSETS   = {};
    013 my  $REWIND    = 10;
    014 
    015 my @VIDEOS  = sort { -M $a <=> -M $b } 
    016               (<*.mp4>, <*.avi>);
    017 
    018 if(-f $YAML_FILE) {
    019   $OFFSETS = LoadFile( $YAML_FILE );
    020 }
    021 
    022 POE::Session->create(
    023   inline_states => {
    024     _start     => \&ui_start,
    025     play_video => \&play_video,
    026     click      => \&click,
    027     output     => \&stdout_handler,
    028     play_ended => \&play_ended,
    029 });
    030 
    031 $poe_kernel->run();
    032 exit 0;
    033 
    034 ###########################################
    035 sub play_ended {
    036 ###########################################
    037   my($kernel, $heap) = @_[KERNEL, HEAP];
    038 
    039   DumpFile( $YAML_FILE, $OFFSETS );
    040   listbox_redraw($heap->{slist});
    041 }
    042 
    043 ###########################################
    044 sub click {
    045 ###########################################
    046   my($kernel, $session, $gtk_list_data) =
    047                  @_[KERNEL, SESSION, ARG1];
    048 
    049   my ($sl, $path) = @$gtk_list_data;
    050   my $row_ref = 
    051       $sl->get_row_data_from_path($path);
    052 
    053     $kernel->yield("play_video", 
    054                    $row_ref->[1]);
    055 }
    056 
    057 ###########################################
    058 sub ui_start {
    059 ###########################################
    060   my ($kernel, $session, $heap) = 
    061                  @_[KERNEL, SESSION, HEAP];
    062 
    063   $heap->{main_window} = 
    064             Gtk2::Window->new ('toplevel');
    065 
    066   $kernel->signal_ui_destroy(
    067                 $heap->{main_window});
    068 
    069   $heap->{slist} = Gtk2::SimpleList->new (
    070     'Timer'    => 'text',
    071     'Video'    => 'text',
    072   );
    073 
    074   listbox_redraw( $heap->{slist} );
    075 
    076   $heap->{slist}->signal_connect(
    077       row_activated => 
    078           $session->callback("click"));
    079 
    080   $heap->{main_window}->add(
    081                          $heap->{slist});
    082   $heap->{main_window}->show_all;
    083 }
    084 
    085 ###########################################
    086 sub listbox_redraw {
    087 ###########################################
    088     my($slist) = @_;
    089 
    090     @{$slist->{data}} = (
    091        map { [ timer($_), $_ ] } @VIDEOS
    092     );
    093 }
    094 
    095 ###########################################
    096 sub timer {
    097 ###########################################
    098     my($video) = @_;
    099 
    100     my $sec = 0;
    101     $sec = $OFFSETS->{$video} if 
    102                 exists  $OFFSETS->{$video};
    103 
    104     return sprintf("%02d:%02d:%02d", 
    105         int($sec/(60*60)), 
    106         ($sec/60)%60, $sec%60);
    107 }
    108 
    109 ###########################################
    110 sub play_video {
    111 ###########################################
    112   my ($kernel, $session, $heap, $video) =
    113            @_[KERNEL, SESSION, HEAP, ARG0];
    114 
    115   my $offset = 0;
    116 
    117   $offset = $OFFSETS->{ $video } - $REWIND 
    118     if exists $OFFSETS->{ $video } 
    119        and $OFFSETS->{ $video } > $REWIND;
    120 
    121   my $wheel =
    122     POE::Wheel::Run->new(
    123       Program     => "/usr/bin/mplayer",
    124       ProgramArgs => 
    125           ["-fs", "-ss", $offset, $video],
    126       StdoutFilter => 
    127                POE::Filter::Stream->new(),
    128       StdoutEvent => 'output',
    129       CloseEvent  => 'play_ended',
    130   );
    131 
    132   $heap->{video} = $video;
    133   $kernel->sig_child( $wheel->PID(), 
    134                       'sig_child' );
    135 
    136   $heap->{player} = $wheel;
    137 }
    138 
    139 ###########################################
    140 sub stdout_handler {
    141 ###########################################
    142     my ($heap, $input) = @_[HEAP, ARG0];
    143 
    144     if($input =~ /(?:^| )V:\s*([\d.]+)/m) {
    145         $OFFSETS->{$heap->{video}} = $1;
    146     }
    147 }

[Anmerkung für die Redaktion: Bitte nicht vergessen, den angehängten Patch poe-loop-glib-0.037.patch mit auf den Listing-Server zu stellen].

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2010/05/Perl

[2]

Tagesschau-Download in verschiedenen Formaten: http://www.tagesschau.de/export/video-podcast/webl/tagesschau

[3]

Tivo, der digitale Videorekorder, http://tivo.com

[4]

``Verkehrte Welt'', Michael Schilli, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2010/03/tleW-etrhekreV

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.