GIT repositories

Index page of all the GIT repositories that are clonable form this server via HTTPS. Übersichtsseite aller GIT-Repositories, die von diesem Server aus über git clone (HTTPS) erreichbar sind.

Services

A bunch of service scripts to convert, analyse and generate data. Ein paar Services zum Konvertieren, Analysieren und Generieren von Daten.

GNU octave web interface

A web interface for GNU Octave, which allows to run scientific calculations from netbooks, tables or smartphones. The interface provides a web form generator for Octave script parameters with pre-validation, automatic script list generation, as well presenting of output text, figures and files in a output HTML page. Ein Webinterface für GNU-Octave, mit dem wissenschaftliche Berechnungen von Netbooks, Tablets oder Smartphones aus durchgeführt werden können. Die Schnittstelle beinhaltet einen Formulargenerator für Octave-Scriptparameter, mit Einheiten und Einfabevalidierung. Textausgabe, Abbildungen und generierte Dateien werden abgefangen und in einer HTML-Seite dem Nutzer als Ergebnis zur Verfügung gestellt.

Klasse zum automatischen Erstellen von Podcast Feeds

Class for automatically generating Podcast feeds

Using this "module" you can let your server automatically generate one or more podcast feeds from a directory (or more, respecively) containing media files such as videos, audio files (and soon as well) PDFs or EPUBs. It is based on the three classes:

  • class PodcastFeedItem and class PodcastFeed, which generate a XML podcast feed as defined in the standard. They are based on class Rss2FeedItem and Rss2Feed.

  • class AutoPodcast, which automates generating the feeds from media folders.

Other classes used are ResourceFile, Json, FileSystem, FfmpegFile (and more).

AutoPodcast is optimised to run on a Linux/Unix System and to be configurable under other Platforms with transfer methods like FTP, means no symbolic links are required, the storage locations for the cache, the source list directory and media directories are flexible.

The system runs as normal PHP script in Apache2 context, so you have full control over access (user management, htaccess, ip ranges ...), design of the cast list page. Media file directories don't need to be in the server document root, they can be stored anywhere you like. The control what metadata and icon a podcast has is done in a subfolder named autocast. There you store small text files named title, description, author, creator and a PNG for a custom icon if you like. The control where to find each podcast media directory is done in a central folder sources (or what you name it in the config). There you put either synlinks or simply text files containing the full path to each podcast media folder.

AutoPodcast scans the media directories recursively using configurable file patterns, e.g. *.mp3|*.mp3|*.m4a, it reads the metadata of each media file to get the episode title / description / etc, creates caches of these information, updates them only if required (by modification time), and generates the XML feed. The download-Management of the media file streams is managed as well.

That means for you:

  • You setup your system (design, access rights) as shown in the example.

  • You create (or have) the media file folders, and add a autocast directory for each podcast you like to generate.

  • You add the media file directories to the cast list by putting either a symlink or a text file with the full path to the media directory in the source folder.

  • Done. When you browse to the podcast base URL the feeds will be created and the list of casts will be shown.

  • With the additional methods shown in the example you can implement additional functionality. You have complete and easy access to all Data that the system generates.

In the following example I put all data in one directory structure for a better overview. In the corresponding section below this structure is described.

Further note: You need to install ffmpeg, because this package is used to read the audio/video metadata.

Mit mit diesem "Modul" ist es möglich aus einem Medienverzeichnis, welches Videos, Audiodateien (bald auch) PDFs oder ePub enthält, einen Podcast-Feed inklusive Download der Mediendaten automatisch erstellen zu lassen. Der Begriff "Modul" wird hier deshalb verwendet, weil hier mehrere Klassen zum Einsatz kommen insbesondere:

  • class PodcastFeedItem und class PodcastFeed, die den XML feed entsprechend dem Standard erstellen und auf class Rss2FeedItem und Rss2Feed basieren.

  • class AutoPodcast, welche für die automatische Generierung sorgt.

Unter anderem werden die Klassen ResourceFile, Json, FileSystem, FfmpegFile und weitere intern eingabunden.

AutoPodcast ist darauf optimiert auf Linux/Unix-Systemen zu laufen und von unterschiedlichen Plattformen aus mit FTP oder anderen Transfermethoden verwaltet werden können, d.h. flexible Speicherorte und Verzeichnisstrukturen, keine Symlinks benötigt etc.

Das System läuft als normales PHP Script im Apache Server, somit kann sowohl das Design als auch Nutzerrechte o.ä. im gewohnten Gang in PHP gelöst werden. Mediendateien können in unterschiedlichen Verzeichnissen, auch auf separaten Laufwerken abgelegt werden. Gesteuert wird die Generierung der Feeds zum einen durch einen "Verteiler" (das "source"-Verzeichnis) und durch ein Metadatenverzeichnis bei den Mediendateien. Im source-Verzeichnis werden Dateien abgelegt, die beschreiben wo die Medienverzeichnisse zu finden sind. Ein spezieller Ordner namens autocast wird in jedem Medienverzeichnis abgelegt. In ihm sind Dateien, die den Titel, Beschreibung, Author, Icon etc für den jeweiligen Feed festlegen. AutoPodcast scannt die Medienordner rekursiv (mit Pattern-Matching wie .mp3|.mp4), liest die Metadaten jeder Mediendatei ein, cached diese und fügt alle Dateien in einem XML Podcast-Feed zusammen. Auch das Download-Managemenet übernimmt es.

Das bedeutet:

  • Einmal das System aufsetzen, Design, Rechte etc

  • Für jeden neuen Cast ein autocast Unterordner erstellen und die Metadaten dort angeben.

  • Für jeden neuen Cast eine "Verknüpfung" im source-Ordner erstellen, um dem System zu sagen, aus welchem neuen Medienverzeichnis ein Cast erstellt werden soll.

  • Fertig. Wenn mit dem Browser die Seite angesteuert wird, so werden die Casts automatisch erstellt und die Liste angezeigt.

  • Mithilfe weiterer Funktionen, die im Beispiel angegeben sind, können weitere PHP-Skripte mit Sonderfunktionen leicht erstellt werden. An alle Rohdaten kommt man ran.

Details sind aus dem Beispiel ersichtlich. Bei der Angabe der Verzeichnisstruktur des Beispiels sind die Bedeutungen der Verzeichnisse nochmals genauer erläutert.

Anmerkung: ffmpeg muss installiert sein, denn dieses Kommandozeilenprogramm wird verwendet um die Metadaten der Audio-/Videodateien einzulesen.

Example: index.php

Beispiel: index.php

<?php
require_once('swlib/swlib.class.php');
 
//
// Check that only accessible from LAN.
// As example, lot of people use 192.168.0.XXX, as /24 LAN address range.
//
if(strpos(\sw\Session::getClientIpAddress(), '192.168.0') === false && !\sw\swlib::isCLIMode()) {
  header('Service Temporarily Unavailable', true, 503);
  die("+++ Not available here +++");
}
 
// This is our podcast root uri
$ROOT_URI = 'http://my-lan-server/autocast';
 
// This is where we store our
$WORK_DIR = __DIR__ . "/workdir";
 
// This is where the autocast system looks for media symlinks or text files
// containing the location or the media source directories to scan.
$SOURCELINK_DIR = __DIR__ .  "/workdir/sources";
 
// This is the cache directory where intermediate files are stored.
// For this example I put it into the workdir and made it rwxrwxrx, but normally
// this should be in somewhere in /tmp/.
$CACHE_DIR = __DIR__ .  "/workdir/cache";
 
// The log file
$LOG_FILE = $CACHE_DIR . '/log.txt';
 
// The log level
$LOG_LEVEL = 2;
 
// List page PHP script template that generates a HTML page to show, so that
// you can click somewhere to subscribe to your existing casts.
$PHP_CAST_LIST_SCRIPT = __DIR__ . '/list.php';
 
// This is the podcast icon used if no other icon is specified.
$DEFAULT_ICON = __DIR__ . '/podcast.png';
 
// Create directoroes id not existing and if we can
if(!is_dir($WORK_DIR)) @mkdir($WORK_DIR);
if(!is_dir($SOURCELINK_DIR)) @mkdir($SOURCELINK_DIR);
if(!is_dir($CACHE_DIR)) @mkdir($CACHE_DIR);
 
// We don't trace ffmpeg here, it will blow up the log file too much
\sw\Tracer::tracedClass('FfmpegFile', false);
 
if(filesize($LOG_FILE) > 10e6) {
  @unlink($LOG_FILE);
}
 
// Ok, generate the autocast and configure it. Then run main().
// Exceptions go to the log file
try {
  $ac = new \sw\AutoPodcast();
  $ac->rootUri = $ROOT_URI;
  $ac->logFile = $LOG_FILE;
  $ac->logLevel = $LOG_LEVEL;
  $ac->cacheDirectory = $CACHE_DIR;
  $ac->sourceDirectory = $SOURCELINK_DIR;
  $ac->castListPageView = $PHP_CAST_LIST_SCRIPT;
  $ac->defaultIcon = $DEFAULT_ICON;
  $ac->defaultMediaFilePattern = "*.mp4|*.mp3|*.m4a";
  $ac->main();
} catch(\Exception $e) {
  @file_put_content($LOG_FILE, "\n\nUNCAUGHT EXCEPTION: $e\n\n", FILE_APPEND);
  die("\nUNCAUGHT EXCEPTION: $e");
}
 
//
// Other methods you can use:
//
if(false) {
 
  // Get the list of all registered casts
  $list = $ac->getCasts();
 
  // Get all episodes of a cast
  $episodes = $ac->getEpisodeList($cast_name);
 
  // Returns the source media folder of a cast
  $media_folder = $ac->getSource($cast_name);
 
  // Check if a cast is up-to-date
  $ac->checkUpdate($cast_name);
 
  // Update one or more casts, optionally force to re-read all media files
  $ac->update($cast_name_to_update_or_null_for_all, $force_update_false_true);
 
  //
  // METHODS THAT YOU CAN OVERLOAD, E.G. NOT TO USE FFMPEG BUT SOMETHING
  // ELSE TO GET FILE META DATA
  //
  $ac->readAudioFileInfo($file);
  $ac->readVideoFileInfo($file);
  $ac->readDocumentFileInfo($file); // !!! NOT IMPLEMENTED !!!
 
}

Example: .htaccess of this example

Beispiel: .htaccess - Datei dieses Beispiels

Order Deny,Allow
Deny from all
Allow From 192.168.0.0/24
Allow From localhost 127.0.0.1 ::1
Satisfy any

Options +FollowSymLinks
RewriteEngine on
RewriteBase /autocast
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule (.*) index.php [L]

Cast list PHP script of this example

Castlist PHP Skript dieses Beispiels

<!doctype html><html><head>
  <title>Auto Podcast example [atwillys.de]</title>
</head><body>
  <h1 class="podcast-list">Podcastcast list</h1>
  <ul id="podcast-list"><?
     foreach($casts as $cast) {
       print "<li><a href=\"" . $this->rootUri . "/" . urldecode($cast['name']) . "/\">" . htmlspecialchars($cast['title']) . "</a></li>\n";
     }
   ?></ul>
</body></html>

Directory structure of this example

Some annotations:

  • index.php and list.php are the files you see above.

  • The media folder is normally somwhere else, it does not need to be in the document root neither. It must contain a folder autocast, where some text files are. Each text file (except description) has one line. The name of the text file says what information it is. The AutoPodcast class parses these files. Additionally you can put a png file in there, which will be used as cast icon in the XML. This media source folder is scanned recuesively for all mediafiles specified (default is *.mp4). The only restriction: ffmpeg must be able to read it.

  • The sources directory is something like the index for the AutoPodcast class where to look for media. That means you can have multiple podcasts at different locations, even on different disks or partitions. All you need to do is either to make symlinks to the media locations there or put text files with one line in there, where the one line is the full path to the media folder pointed to. (This media folder has a autocast subfolder where the details can be found. The name of symlink or text file will be used as the resource identifier for the podcast feed URL. So in this case it is dead-gentlemen with the line /var/www/workdir/media/dead-gentlemen. That means the cast XML will be found under http://my-server/autocast/dead-gentlemen/.

  • Last but not least the cache folder. Of cause if mus be writable for the web server. All ffmpeg results are saved in JSON files, which are only updated if the original media file is newwer than the JSON file, or new, or deleted. The icon for each cast is copied as well, and each cast gets an own cache XML file. Cast list and log.txt are self explaining.

Verzeichnisstruktur dieses Beispiels

Hierzu noch ein paar Anmerkungen:

  • index.php and list.php sind die oben gelisteten Dateien.

  • Das media-Verzeichnis ist normalerweise wo anders, es muss nicht einmal in der "Document Root" des Servers sein. Es muss aber ein Unterverzeichnis autocast enthalten, in dem Metadaten über den Podcast stehen. Das sind Textdateien mit definiertem Namen wie title, description usw., die (mit Ausnahme von description aus einer Zeile bestehen. Die AutoPodcast Klasse parsed diese Dateien für den Feed. Zusätzlich kann ein PNG in diesem Verzeichnis abgelegt werden, welches dann als Icon für diesen Podcast verwendet wird. Die Medienverzeichnisse werden rekursiv nach Dateien mit definierten Endungen (default *.mp4) gescannt. Einzige Einschränkung: ffmpeg muss diesen Dateityp lesen können (für PDF und Ebook habe ich noch keine Handler implementiert).

  • Das sources Verzeichnis ist so etwas wie ein erster "Verteiler", der in dem die Medienverzeichnisse für alle Podcasts angegeben werden (, die dann wiederum den oben beschriebenen autocast-Ordner haben). Dazu werden einfach Symlinks gemacht oder Textdatei abgelegt, in der in einer Zeile der volle Pfad zu den Medienverzeichnissen steht. Der name der Textdatei bzw. des Symlinks wird verwendet, um die Feed-URL festzulegen. D.h. in diesem Fall ist nur eine Datei dead-gentlemen da, in der die Zeile /var/www/workdir/media/dead-gentlemen steht. Damit sucht das System in unserem Mediafolder, und der Podcast ist unter http://my-server/autocast/dead-gentlemen/ erreichbar.

  • Last but not least: Der Cache. Metadaten der Mediendateien werden gecacht und nur aktualisiert wenn die Mediendatei neuer ist als die Cachedatei, oder wenn sie neu oder gelöscht ist. Jeder Podcast hat eine Cache-XML-Datei, die normalerweise direkt als Feed zurückgegeben wird. Cast-Liste und Logdatei sind selbsterklärend.

.
├── index.php
├── list.php
├── podcast.png
└── workdir
    ├── cache
    │   ├── castlist.json
    │   ├── dead-gentlemen
    │   │   ├── JourneyQuest S01E01 - Onward.mp4.json
    │   │   ├── JourneyQuest S01E02 - Sod the Quest.mp4.json
    .   .   .   [...]
    │   │   └── JourneyQuest S02E10 - Through Every Trial.mp4.json
    │   ├── dead-gentlemen.png
    │   ├── dead-gentlemen.xml
    │   └── log.txt
    ├── media
    │   └── dead-gentlemen
    │       ├── autocast
    │       │   ├── author
    │       │   ├── author-email
    │       │   ├── description
    │       │   ├── genre
    │       │   ├── icon.png
    │       │   └── title
    │       ├── JourneyQuest S01E01 - Onward.mp4
    │       ├── JourneyQuest S01E02 - Sod the Quest.mp4
    .   .   .   [...]
    │       └── JourneyQuest S02E10 - Through Every Trial.mp4
    └── sources
        └── dead-gentlemen

7 directories, 44 files7 directories, 44 files

Output

Ausgabe

HTML LIST, curl http://my-server/autocast/:

<!doctype html><html><head>
  <title>Auto-Podcast Example [atwillys.de]</title>
</head><body>
   <h1 class="podcast-list">Podcastcast list</h1>
   <ul id="podcast-list"><li><a href="http://my-server/autocast/dead-gentlemen/">Dead Gentlemen</a></li>
</ul>
</body></html>

XML CAST, curl http://my-server/autocast/dead-gentlemen/:

<?xml version="1.0" encoding="UTF-8"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:media="http://search.yahoo.com/mrss/" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0"
version="2.0">

<channel>
<title>Dead Gentlemen</title>
<description>All they do.</description>
<link>http://my-server/autocast/dead-gentlemen/</link>
<lastBuildDate>Sun, 01 Sep 2013 18:56:22 +0200</lastBuildDate>
<language>en-en</language>
<sy:updatePeriod>hourly</sy:updatePeriod><sy:updateFrequency>1</sy:updateFrequency>
<generator>swlib PodcastFeed.class</generator><managingEditor></managingEditor>
<webMaster></webMaster>
<ttl>120</ttl>
<image><url>http://my-server/autocast/dead-gentlemen.png</url>
<title>Dead Gentlemen</title><link>http://my-server/autocast/dead-gentlemen/</link>
<width>300</width><height>300</height></image>
<itunes:subtitle>All they do.</itunes:subtitle>
<itunes:summary>All they do.</itunes:summary>
<itunes:keywords></itunes:keywords>
<itunes:author />
<itunes:block>no</itunes:block>
<itunes:explicit>no</itunes:explicit>
<itunes:image href="http://my-server/autocast/dead-gentlemen.png" />
<itunes:owner><itunes:email></itunes:email></itunes:owner>
<itunes:category text="" />
<item>
 <title>JourneyQuest S01E01 - Onward</title>
 <description></description>
 <pubDate>Sun, 01 Sep 2013 18:41:22 +0200</pubDate>
 <dc:creator></dc:creator>
 <category><![CDATA[TV & Film]]></category>
 <content:encoded></content:encoded>
 <itunes:duration>00:07:34</itunes:duration>
 <itunes:subtitle></itunes:subtitle>
 <itunes:summary></itunes:summary>
 <itunes:keywords>Podcast</itunes:keywords>
 <itunes:author>Vancil</itunes:author>
 <itunes:explicit>no</itunes:explicit>
 <itunes:block>no</itunes:block>
 <enclosure url="http://my-server/autocast/dead-gentlemen/JourneyQuest+S01E01+-+Onward.mp4"
  length="231720220" type="video/mp4" />
</item>
<item>
 <title>JourneyQuest S01E02 - Sod the Quest</title>
 <description></description>
 <pubDate>Sun, 01 Sep 2013 18:41:59 +0200</pubDate>

 <!-- [... OUTPUT CUT ] -->
</item>
</channel>
</rss>

Class source code

Klassen-Quelltext

<?php
 
/**
 * Provides rendering a podcast channel item into a RSS XML text.
 * @gpackage de.atwillys.sw.php.swLib
 * @author Stefan Wilhelm
 * @copyright Stefan Wilhelm, 2011
 * @license GPL
 * @version 1.0
 */
 
namespace sw;
 
class PodcastFeedItem {
 
  // Feed item properties
  public $parentPodcast = null;
  public $title = '';
  public $description = '';
  public $pageLink = '';
  public $commentsPageLink = '';
  public $datePublished = '';
  public $author = '';
  public $authorEmail = '';
  public $category = '';
  public $keywords = '';
  public $duration = '';
  public $subtitle = '';
  public $explicit = false;
  public $block = false;
  public $fileUrl = '';
  public $fileLength = '';
  public $fileMimeType = '';
 
  /**
   *
   * @param type $path
   * @param type $parentPodcast
   */
  public function __construct($path='', $parentPodcast) {
 
    // Initialize by copying first
    if ($parentPodcast instanceof PodcastFeed) {
      $this->parentPodcast = $parentPodcast;
      $this->author = $parentPodcast->author;
      $this->authorEmail = $parentPodcast->authorEmail;
      $this->category = 'Podcast';
      $this->pageLink = '';
    }
 
    // Get infos from the file itself by reading the first part of the file using
    // a GET request.
    $path = trim($path);
    if (!empty($path)) {
      if (FileSystem::isFile($path)) {
        $ffmpeg = new FfmpegFile($path);
        $this->title = FileSystem::getBasename(FileSystem::getFileNameWithoutExtension($path)) . ' (' . date("Y-m-d h:i", FileSystem::getLastModified($path)) . ')';
        $this->fileUrl = $parentPodcast->urlRoot . '/' . trim(str_replace(array_keys($parentPodcast->fileToUrlTransformationRules) , array_values($parentPodcast->fileToUrlTransformationRules), $path), '/ ');
        $this->fileLength = FileSystem::getFileSize($path);
        $this->datePublished = FileSystem::getLastModified($path);
        $this->fileMimeType = mime_content_type($path);
        $this->duration = $ffmpeg->getDuration();
      }
    }
  }
 
  /**
   * Renders the feed item
   * @return string
   */
  public function compose() {
    return
      '<item>' . "\n"
      . ' <title>' . PodcastFeed::xmlEscape($this->title) . '</title>' . "\n"
      . ' <description>' . PodcastFeed::xmlEscape($this->description) . '</description>' . "\n"
      . (empty($this->pageLink) ? '' : (' <link>' . PodcastFeed::xmlEscape($this->pageLink) . '</link>' . "\n"))
      . (empty($this->commentsPageLink) ? '' : (' <comments>' . PodcastFeed::xmlEscape($this->commentsPageLink) . '</comments>' . "\n"))
      . ' <pubDate>' . PodcastFeed::xmlEscape(date(DATE_RFC1123, $this->datePublished)) . '</pubDate>' . "\n"
      . (empty($this->authorEmail) ? '' : (' <dc:creator>' . PodcastFeed::xmlEscape($this->authorEmail) . '</dc:creator>' . "\n"))
      . ' <category>' . PodcastFeed::xmlEscape($this->category) . '</category>' . "\n"
      . (empty($this->pageLink) ? '' : (' <guid isPermaLink="false">' . PodcastFeed::xmlEscape($this->pageLink) . '</guid>' . "\n"))
      . ' <content:encoded>' . PodcastFeed::xmlEscape($this->description) . '</content:encoded>' . "\n"
      . ' <itunes:duration>' . PodcastFeed::xmlEscape($this->duration) . '</itunes:duration>' . "\n"
      . ' <itunes:subtitle>' . PodcastFeed::xmlEscape($this->description) . '</itunes:subtitle>' . "\n"
      . ' <itunes:summary>' . PodcastFeed::xmlEscape($this->description) . '</itunes:summary>' . "\n"
      . ' <itunes:keywords>Podcast</itunes:keywords>' . "\n"
      . ' <itunes:author>' . PodcastFeed::xmlEscape($this->author) . '</itunes:author>' . "\n"
      . ' <itunes:explicit>' . ($this->explicit ? 'yes' : 'no') . '</itunes:explicit>' . "\n"
      . ' <itunes:block>' . ($this->block ? 'yes' : 'no') . '</itunes:block>' . "\n"
      . ' <enclosure url="' . dirname($this->fileUrl) . '/' . urlencode(basename($this->fileUrl)) . '" length="' . PodcastFeed::xmlEscapeAttribute($this->fileLength) . '" type="' . PodcastFeed::xmlEscapeAttribute($this->fileMimeType) . '" />' . "\n"
      . '</item>' . "\n"
    ;
  }
 
  /**
   * Returns the rendered xml version of the item
   * @return string
   */
  public function __toString() {
    return $this->compose();
  }
 
 
}
<?php
 
/**
 * Provides rendering one podcast channel into a RSS XML text. Assumed is that
 * there is only one channel in the feed.
 * @gpackage de.atwillys.sw.php.swLib
 * @author Stefan Wilhelm
 * @copyright Stefan Wilhelm, 2011
 * @license GPL
 * @version 1.0
 */
 
namespace sw;
 
class PodcastFeed {
 
  /**
   * Title of the podcast
   * @var string
   */
  public $title = '';
 
  /**
   * Description/summary of the podcast
   * @var string
   */
  public $description = '';
 
  /**
   * Link to the main HTML (info/summary etc) page of the cast
   * @var string
   */
  public $pageLink = '';
 
  /**
   * Link to a page where people can leave comments about the cast
   * @var string
   */
  public $commentsPageLink = '';
 
  /**
   * The name of the podcast manager/author
   * @var string
   */
  public $author = '';
 
  /**
   * The email address of the author
   * @var string
   */
  public $authorEmail = '';
 
  /**
   * Category information in the RSS feed
   * @var string
   */
  public $category = '';
 
  /**
   * Keywords in the feed, set the values comma separated
   * @var string
   */
  public $keywords = '';
 
  /**
   * Date when the podcast feed was last actualized. Automatically set in the
   * constructor.
   * @var string
   */
  public $lastBuildDate = '';
 
  /**
   * Language hint in the podcast RSS
   * @var string
   */
  public $language = '';
 
  /**
   * Copyright info in the podcast RSS
   * @var string
   */
  public $copyright = '';
 
  /**
   * Time to live in minutes. This is an info for the client program how frequently
   * the cast shall be polled.
   * @var int
   */
  public $updatePeriodTtlInS = 1440;
 
  /**
   * Link to the cover image. Set it using when setImage()
   * @var int
   */
  public $imageLink = '';
 
  /**
   * Width of the cover image. Automatically set when setImage() is called
   * @var int
   */
  public $imageWidth = 0;
 
  /**
   * Height of the cover image. Automatically set when setImage() is called
   * @var int
   */
  public $imageHeight = 0;
 
  /**
   *
   * @var bool
   */
  public $block = false;
 
  /**
   * Describes if the podcast has to be loaded explicitly (instead of automaticly)
   * @var bool
   */
  public $explicit = false;
 
  /**
   * The root uri of the feed, e.g. "http://www.example.org".
   * @var string
   */
  public $urlRoot = '';
 
  /**
   * An assoc array defining how local media files are mapped to urls. Useful
   * if files are stored in a directory that is not in the $_SERVER["DOCUMENT_ROOT"],
   * but made accessible for HTTP using web server configurations. If empty,
   * the feed item media file locations will assumed to be in a sub directory of
   * the DOCUMENT_ROOT, which will be removed from the string to retain
   * the file URI. E.g.
   *
   * $feed->fileToUrlTransformationRules = array(
   *    // PATTERN
   *    'LOCAL PATH' => 'URL PREFIX WITHOUT THE URL ROOT',
   *
   *    // Standard behavior
   *    $_SERVER['DOCUMENT_ROOT'] => '',
   *
   *    // E.g. in apache2.conf the directory media of mounted diskN was made
   *    // available as https://localhost/media
   *    '/mnt/diskN/media' => '/media'
   * );
   *
   * @var array
   */
  public $fileToUrlTransformationRules = array();
 
  /**
   * Contains the channel items
   * @var PodcastFeedItem[]
   */
  public $items = array();
 
  /**
   * Escapes a string, so that it is compliant to XML node content texts. Preferred
   * escapint is <![CDATA[...]]>, if this is not possible, XML special chaar
   * escaping is used.
   * @param string $text
   * @return string
   */
  public static function xmlEscape($text) {
    if (empty($text) || trim($text) == '') { // !empty for array()/null
      return '';
    } else if (!preg_match('/([^\x01-\x7f]|[&<>])/', $text)) {
      return $text;
    } if (strpos($text, ']]>') === false) {
      $text = mb_convert_encoding($text, 'UTF-8', 'auto');
      return "<![CDATA[$text]]>";
    } else {
      return str_replace(array('&', '"', "'", '<', '>'), array('&amp;', '&quot;', '&apos;', '&lt;', '&gt;'), $text);
    }
  }
 
  /**
   * Escapes a text so that it is compliant to XML attribute values
   * @param string $text
   * @return string
   */
  public static function xmlEscapeAttribute($text) {
    $text = mb_convert_encoding($text, 'UTF-8', 'auto');
    return htmlspecialchars($text, ENT_COMPAT | ENT_XML1, 'UTF-8');
  }
 
  /**
   * Constructor
   */
  public function __construct($baseUri=null, $title='', $description='', $author='', $authorEmail='', $fileToUrlTransformationRules=array()) {
    if ($baseUri === null) {
      $this->urlRoot = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on' ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . '/';
    } else {
      $this->urlRoot = $baseUri;
    }
 
    if(empty($fileToUrlTransformationRules) || !is_array($fileToUrlTransformationRules)) {
      $this->fileToUrlTransformationRules = array(
          $_SERVER['DOCUMENT_ROOT'] => ''
      );
    } else {
      $this->fileToUrlTransformationRules = $fileToUrlTransformationRules;
    }
 
    $this->urlRoot = rtrim($this->urlRoot, '/ ');
    $this->title = trim($title);
    $this->description = trim($description);
    $this->author = trim($author);
    $this->authorEmail = trim($authorEmail);
    $this->lastBuildDate = date(DATE_RSS, time());
    $this->language = 'en-en';
    $this->pageLink = $this->urlRoot . '/';
    //$this->authorEmail =
  }
 
  /**
   * Sets the image file and reads the image dimensions
   * @param string $link
   */
  public function setImage($link) {
    if (empty($link)) {
      $this->imageLink = '';
      $this->imageHeight = '';
      $this->imageWidth = '';
      return;
    }
    if (stripos($link, '://') === false) {
      $link = $this->urlRoot . '/' . ltrim($link, '/');
    }
    $http = new HttpRequest($link);
    $http->setThrowExceptionOnErrorResponse(true);
    $http->request();
    $file = FileSystem::getTempDirectory() . '/' . FileSystem::getBasename($link);
    FileSystem::writeFile($file, $http->getResponseBody());
    $size = @getimagesize($file);
    FileSystem::unlink($file);
    if (empty($size)) {
      throw new LException("Failed to read image size of: :file", array(':file' => $file));
    }
    $this->imageWidth = $size[0];
    $this->imageHeight = $size[1];
    $this->imageLink = $link;
  }
 
  /**
   * Adds a new feed item by the specified media file path (in the DOCUMENT_ROOT).
   * The item will be initialized with a copy of the matching feed channel data
   * (like author) and data imported from the file.
   * @param string $path
   * @return PodcastFeedItem
   */
  public function & addItem($path) {
    $this->items[$path] = new PodcastFeedItem($path, $this);
    return $this->items[$path];
  }
 
  /**
   * Renders a podcast channel with surrounding XML/RSS wrapping
   * @return string
   */
  public function compose() {
    // Start RSS
    $xml = '<?xml version="1.0" encoding="UTF-8"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:media="http://search.yahoo.com/mrss/" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" version="2.0">' . "\n";
 
    // Channel contents
    $xml .= "\n" . '<channel>' . "\n"
            . '<title>' . self::xmlEscape($this->title) . '</title>' . "\n"
            . '<description>' . self::xmlEscape($this->description) . '</description>' . "\n"
            . (empty($this->pageLink) ? '' : ('<link>' . self::xmlEscape($this->pageLink) . '</link>' . "\n"))
            . (empty($this->lastBuildDate) ? '' : ('<lastBuildDate>' . self::xmlEscape($this->lastBuildDate) . '</lastBuildDate>' . "\n"))
            . (empty($this->language) ? '' : ('<language>' . self::xmlEscape($this->language) . '</language>' . "\n"))
            . ('<sy:updatePeriod>hourly</sy:updatePeriod>')
            . ('<sy:updateFrequency>1</sy:updateFrequency>')
            . ('<generator>swlib PodcastFeed.class</generator>')
            . (empty($this->copyright) ? '' : ('<copyright>' . self::xmlEscape($this->copyright) . '</copyright>' . "\n"))
            . (empty($this->authorEmail) ? '' : ('<managingEditor>' . self::xmlEscape($this->authorEmail) . '</managingEditor>' . "\n"))
            . (empty($this->authorEmail) ? '' : ('<webMaster>' . self::xmlEscape($this->authorEmail) . '</webMaster>' . "\n"))
            . ('<ttl>' . self::xmlEscape($this->updatePeriodTtlInS) . '</ttl>' . "\n")
            . (empty($this->imageLink) ? '' : ('<image><url>' . self::xmlEscape($this->imageLink)
                    . '</url><title>' . self::xmlEscape($this->title) . '</title>'
                    . '<link>' . self::xmlEscape($this->pageLink) . '</link><width>' . self::xmlEscape($this->imageWidth) . '</width>'
                    . '<height>' . self::xmlEscape($this->imageHeight) . '</height></image>' . "\n"))
            . '<itunes:subtitle>' . self::xmlEscape($this->description) . '</itunes:subtitle>' . "\n"
            . '<itunes:summary>' . self::xmlEscape($this->description) . '</itunes:summary>' . "\n"
            . '<itunes:keywords>' . self::xmlEscape($this->keywords) . '</itunes:keywords>' . "\n"
            . '<itunes:author />' . "\n"
            . '<itunes:block>' . (empty($this->block) || $this->block == 'no' || $this->block == 'false' ? 'no' : 'yes') . '</itunes:block>' . "\n"
            . '<itunes:explicit>' . (empty($this->explicit) || $this->explicit == 'no' || $this->explicit == 'false' ? 'no' : 'yes') . '</itunes:explicit>' . "\n"
            . '<itunes:image href="' . self::xmlEscapeAttribute($this->imageLink) . '" />' . "\n"
            . '<itunes:owner><itunes:email>' . self::xmlEscape($this->authorEmail) . '</itunes:email></itunes:owner>' . "\n"
            . '<itunes:category text="' . self::xmlEscapeAttribute($this->category) . '" />' . "\n"
    ;
 
    foreach ($this->items as $k => $v) {
      $xml .= $v->compose();
    }
 
    $xml .= "\n" . '</channel>' . "\n";
    $xml .= '</rss>' . "\n";
    return $xml;
  }
 
  /**
   * String conversion function (identical to compose())
   * @return string
   */
  public function __toString() {
    return $this->compose();
  }
 
  /**
   *
   * @param string $xml_string
   * @return PodcastFeed
   */
  public static function parse($xml_string) {
    $channel = Xml::fromXmlString($xml_string)->toAssocArray();
    if(!isset($channel['rss']) || !isset($channel['rss']['channel'])) {
      throw new LException('The XML string is not a valid podcast feed (tag "rss" or "rss/channel" is missing.');
    }
 
    $itunes_ns = '';
    foreach($channel['rss'] as $k => $v) {
      if(is_scalar($v) && stripos($v, 'www.itunes.com') !== false) {
        $itunes_ns = trim(str_ireplace('xmlns:', '', $k));
        Tracer::trace("Found iTunes XML NS: $itunes_ns");
        break;
      }
    }
 
//    Tracer::trace_r($channel, 'Parsed XML feed');
 
    $channel = $channel['rss']['channel'];
 
    $feed = new self('');
    $feed->title = trim(isset($channel['title']) ? trim($channel['title']) : '', "\n\r\t ");
    $feed->description = trim(isset($channel['description']) ? trim($channel['description']) : '', "\n\r\t ");
    $feed->author = trim(isset($channel['managingeditor']) ? trim($channel['managingeditor']) : '');
    $feed->authorEmail = trim(isset($channel['webmaster']) ? trim($channel['webmaster']) : '');
    $feed->language = strtolower(trim(isset($channel['language']) ? trim($channel['language']) : ''));
    $feed->lastBuildDate = trim(isset($channel['lastbuilddate']) ? trim($channel['lastbuilddate']) : '');
    $feed->pageLink = trim(isset($channel['link']) ? trim($channel['link']) : '');
    $feed->updatePeriodTtlInS = intval(isset($channel['ttl']) ? trim($channel['ttl']) : $feed->updatePeriodTtlInS);
    $feed->copyright = trim(isset($channel['copyright']) ? trim($channel['copyright']) : '');
 
    if(isset($channel['image']) && isset($channel['image']['link']) && isset($channel['image']['width']) && isset($channel['image']['height'])) {
      $feed->imageLink = $channel['image']['link'];
      $feed->imageWidth = intval($channel['image']['width']);
      $feed->imageHeight = intval($channel['image']['height']);
    }
 
    $feed->category = isset($channel["$itunes_ns:category"]) && isset($channel["$itunes_ns:category"]['text']) ? trim($channel["$itunes_ns:category"]['text']) : 'Podcast';
    $feed->explicit = isset($channel["$itunes_ns:explicit"]) ? trim($channel["$itunes_ns:explicit"]) : '';
    $feed->keywords = isset($channel["$itunes_ns:keywords"]) ? trim($channel["$itunes_ns:keywords"]) : '';
    $feed->block = isset($channel["$itunes_ns:block"]) ? trim($channel["$itunes_ns:block"]) : false;
 
    // Some corrections
    $feed->block = !empty($feed->block) && (@intval($feed->block) != 0 || in_array(strtolower($feed->block), array('yes', 'true')));
    $feed->explicit = !empty($feed->explicit) && (@intval($feed->explicit) != 0 || in_array(strtolower($feed->explicit), array('yes', 'true')));
 
    // Items
    if(isset($channel['item']) && is_array($channel['item'])) {
      foreach($channel['item'] as $item) {
        if(isset($item['enclosure']) && isset($item['enclosure']['url'])) {
 
          $url = trim(isset($item['enclosure']['url']) ? $item['enclosure']['url'] : '');
          $feed_item = $feed->addItem($url);
          $feed_item->fileUrl = trim(isset($item['enclosure']['url']) ? $item['enclosure']['url'] : '');
          $feed_item->fileLength = trim(isset($item['enclosure']['length']) ? $item['enclosure']['length'] : '');
          $feed_item->fileMimeType = strtolower(trim(isset($item['enclosure']['type']) ? $item['enclosure']['type'] : ''));
          $feed_item->parentPodcast = $feed;
          $feed_item->title = trim(isset($item['title']) ? trim($item['title']) : '', "\n\r\t ");;
          $feed_item->description = trim(isset($item['description']) ? trim($item['description']) : '', "\n\r\t ");;
          $feed_item->pageLink = trim(isset($item['link']) ? trim($item['link']) : '');
          $feed_item->commentsPageLink = trim(isset($item['comments']) ? trim($item['comments']) : '');
          $feed_item->datePublished = trim(isset($item['pubdate']) ? strtotime(trim($item['pubdate'])) : 0);
          $feed_item->author = isset($item["$itunes_ns:author"]) ? trim($item["$itunes_ns:author"]) : '';
          $feed_item->category = isset($item['category']) ? trim($item['category']) : '';
          //$item->authorEmail = '';
          $feed_item->duration = isset($item["$itunes_ns:duration"]) ? trim($item["$itunes_ns:duration"]) : '';
          $feed_item->keywords = isset($item["$itunes_ns:keywords"]) ? trim($item["$itunes_ns:keywords"]) : '';
          $feed_item->subtitle = isset($item["$itunes_ns:subtitle"]) ? trim($item["$itunes_ns:subtitle"]) : '';
          $feed_item->explicit = isset($item["$itunes_ns:explicit"]) ? trim($item["$itunes_ns:explicit"]) : '';
          $feed_item->block = isset($item["$itunes_ns:block"]) ? trim($item["$itunes_ns:block"]) : false;
          // Some corrections
          $feed_item->block = !empty($feed_item->block) && (@intval($feed_item->block) != 0 || in_array(strtolower($feed_item->block), array('yes', 'true')));
          $feed_item->explicit = !empty($feed_item->explicit) && (@intval($feed_item->explicit) != 0 || in_array(strtolower($feed_item->explicit), array('yes', 'true')));
        }
      }
    }
    return $feed;
  }
 
}
<?php
 
/**
 * Automatic podcast generator.
 *
 * @gpackage de.atwillys.sw.php.swLib
 * @author Stefan Wilhelm
 * @copyright Stefan Wilhelm, 2008-2010
 * @license GPL
 */
 
namespace sw;
 
class AutoPodcast {
 
  public $rootUri = null;
  public $sourceDirectory = null;
  public $cacheDirectory = null;
  public $logLevel = 0;
  public $logFile = null;
  public $defaultIcon = null;
  public $castListPageView = null;
  public $defaultMediaFilePattern = '*.mp4'; // *.{mp4,mp3,aac,m4a,m4v,pdf,epub}';
  private $castList = null;
  private $sourceDirs = null;
 
  /**
   *
   */
  public function __construct() {
    ;
  }
 
  /**
   * Returns the media directory of a podcast that is linked/registered in
   * the source directory.
   * @param string $cast
   * @return string
   */
  public function getSource($cast) {
    if ($this->sourceDirs === null) {
      $dirs = array();
      foreach (glob($this->sourceDirectory . "/*") as $link) {
        $dir = null;
        if (is_link($link)) {
          $dir = @readlink($link);
        } else if (is_file($link)) {
          $dir = trim(@file_get_contents($link), "\r\n\t ");
        } else {
          continue;
        }
        $dirs[strtolower(basename($link))] = empty($dir) ? null : ($dir);
      }
      $this->sourceDirs = $dirs;
    }
    $cast = strtolower($cast);
    return isset($this->sourceDirs[$cast]) ? $this->sourceDirs[$cast] : null;
  }
 
  /**
   * Returns the details about all sources
   * @return array
   */
  public function getCasts() {
    if (empty($this->castList)) {
      if (!is_file($this->cacheDirectory . '/castlist.json')) {
        try {
          $this->update();
        } catch (\Exception $e) {
          return array();
        }
      }
      $this->castList = json_decode(file_get_contents($this->cacheDirectory . '/castlist.json'), true);
      if (empty($this->castList))
        $this->castList = array();
    }
    return $this->castList;
  }
 
  /**
   * Returns a detailed list of a podcasts episodes
   * @param string $cast
   */
  public function getEpisodeList($cast) {
    $dir = $this->getCasts();
    $episodes = array();
    if (!isset($dir[strtolower($cast)]))
      return array();
    $cast = $dir[strtolower($cast)];
    $files = glob($cast['cache'] . '/*.json');
    $times = array();
    foreach ($files as $file)
      $times[$file] = filemtime($file);
    asort($times, SORT_DESC);
    $files = array_keys($times);
    unset($times);
    foreach ($files as $file) {
      $ep = @json_decode(@file_get_contents($file), true);
      if (is_array($ep)) {
        unset($ep['local-file']);
        $ep['uri'] = $cast['url'] . $ep['path']; // rawurlencode($ep['path'])
        unset($ep['path']);
      }
      $episodes[] = $ep;
    }
    return $episodes;
  }
 
  /**
   * Retrieves metadata from a video file into an assoc array. Required keys
   * are:
   *  title: string
   *  description: string
   *  comment: string
   *  genre: string
   *  created: timestamp
   *
   * @param string $file
   * @return array
   */
  public function readVideoFileInfo($file) {
    $meta_data = array();
    try {
      $ffmpeg = new FfmpegFile($file);
      try {
        $meta_data['title'] = trim($ffmpeg->getContainerMetaData('title'));
      } catch (\Exception $e) {
        ;
      }
      if (empty($meta_data['title'])) {
        $meta_data['title'] = trim(FileSystem::getFileNameWithoutExtension(FileSystem::getBasename($file)));
      }
      try {
        $meta_data['description'] = trim($ffmpeg->getMetadata('description'));
      } catch (\Exception $e) {
        ;
      }
      try {
        $meta_data['comment'] = trim($ffmpeg->getMetadata('comment'));
      } catch (\Exception $e) {
        ;
      }
      try {
        $meta_data['genre'] = trim($ffmpeg->getMetadata('genre'));
      } catch (\Exception $e) {
        ;
      }
      try {
        if (($t = @strtotime(trim($ffmpeg->getMetadata('date')))) !== false) {
          $meta_data['created'] = $t;
        } else if (($t = @strtotime(trim($ffmpeg->getMetadata('creation_time')))) !== false) {
          $meta_data['created'] = $t;
        } else {
          $meta_data['created'] = filemtime($file);
        }
      } catch (\Exception $e) {
        ;
      }
    } catch(\Exception $e) {
      Tracer::trace("Failed to read video file '$file': $e");
    }
    return $meta_data;
  }
 
  /**
   *
   * @param type $file
   * @return type
   */
  public function readAudioFileInfo($file) {
    return $this->readVideoFileInfo($file); // not yet implemented, use ffmpeg
  }
 
  /**
   *
   * @param type $file
   * @return type
   */
  public function readDocumentFileInfo($file) {
    return array(); // not yet implemented
  }
 
  /**
   *
   * @param string $cast
   */
  public function checkUpdate($cast) {
    // nothing yet
  }
 
  /**
   * Sets, resets or returns the status of the update lock.
   * If null or no argument, returns the current lock state: true=locked.
   * If argument is true/false returns true on success, false on error (means
   * already locked by another script process).
   * @param bool $set_lock=null
   */
  public function lockUpdate($set_lock = null) {
    $lockfile = $this->cacheDirectory . '/.update-lock';
    $locked = (is_file($lockfile) && time() < filemtime($lockfile) + 600);
    if ($set_lock === null) {
      return $locked;
    } else if (empty($set_lock)) {
      @unlink($lockfile);
      return true;
    } else if (!$locked) {
      @file_put_contents($lockfile, time());
      return true;
    }
    return false;
  }
 
  /**
   * Updates one or all podcasts
   * @param string $cast_to_update
   * @param mixed $force_update = false
   */
  public function update($cast_to_update = null, $force_update = false) {
 
    // Be independent from the host connection and lock the update process
    if (!$this->lockUpdate(true)) {
      throw new Exception("Cache is being processed, try later.");
    }
 
    try {
      @ignore_user_abort();
      Tracer::trace('+++ CAST LIST UPDATE +++');
 
      // Check if the castlist file exists or a complete rebuild is required
      $this->castList = json_decode(file_get_contents($this->cacheDirectory . '/castlist.json'), true);
      if (empty($this->castList)) {
        $cast_to_update = null;
        $this->castList = array();
      }
 
      // Scan cast list for changes
      foreach (glob($this->sourceDirectory . "/*") as $source_link) {
        if (!is_file($source_link) && !is_link($source_link))
          continue;
        $cast_name = basename($source_link);
        if ($cast_to_update !== null && strtolower($cast_to_update) != strtolower($cast_name)) {
          continue;
        }
        $this->castList[trim(strtolower($cast_name))] = null;
        $source_dir = $this->getSource($cast_name);
        if (!is_readable($source_dir)) {
          Tracer::trace("Update: Skipped, source directory not readable: '$source_dir' (for link '$source_link')");
          continue;
        } else if (!is_dir($source_dir)) {
          Tracer::trace("Update: Skipped, source directory link target is no directory: '$source_dir' (for link '$source_link')");
          continue;
        }
 
        // Get meta information about the actual scanned cast
        $actual_cast = array('name' => '', 'dir' => '', 'url' => '', 'title' => '',
            'description' => '', 'icon' => '', 'icon-file' => '', 'author' => '',
            'author-email' => '', 'cache' => '');
 
        $actual_cast['name'] = $cast_name;
        $actual_cast['dir'] = $source_dir;
        $actual_cast['url'] = rtrim($this->rootUri, '/') . "/" . $cast_name;
        $actual_cast['icon'] = $actual_cast['url'] . '.png';
        $actual_cast['cache'] = $this->cacheDirectory . "/" . $cast_name;
        $actual_cast['last-modified'] = time();
 
        // Meta data saved in the .autocast directory located where the media files are.
        $info_dir = FileSystem::getOneOf(array("$source_dir/autocast", "$source_dir/.autocast"), false);
        if (is_dir($info_dir)) {
          Tracer::trace("Cast meta directory found: $info_dir", 1);
          $fn = FileSystem::getOneOf(array("$info_dir/title*", "$info_dir/name*"), false);
          $actual_cast['title'] = empty($fn) ? '' : trim(@file_get_contents($fn), "\n\r\t ");
 
          $fn = FileSystem::getOneOf(array("$info_dir/description*"), false);
          $actual_cast['description'] = empty($fn) ? '' : trim(@file_get_contents($fn), "\n\r\t ");
 
          $fn = FileSystem::getOneOf(array("$info_dir/author", "$info_dir/author.*"), false);
          $actual_cast['author'] = empty($fn) ? '' : trim(@file_get_contents($fn), "\n\r\t ");
 
          $fn = FileSystem::getOneOf(array("$info_dir/author-email*"), false);
          $actual_cast['author-email'] = empty($fn) ? '' : trim(@file_get_contents($fn), "\n\r\t ");
 
          $fn = FileSystem::getOneOf(array("$info_dir/filepattern*"), false);
          $actual_cast['filepattern'] = empty($fn) ? '' : trim(@file_get_contents($fn), "\n\r\t ");
 
          if (@count($r = @glob("$info_dir/*.png")) > 0)
            $actual_cast['icon-file'] = @reset($r);
        }
        if (empty($actual_cast['filepattern'])) {
          $actual_cast['filepattern'] = $this->defaultMediaFilePattern;
        }
        $this->castList[trim(strtolower($cast_name))] = $actual_cast;
        @file_put_contents($this->cacheDirectory . '/castlist.json', json_encode($this->castList, JSON_FORCE_OBJECT));
 
        Tracer::trace_r($this->castList, '$this->castList');
 
        // Scan the media files of the actually updated cast
        try {
          $cast_name = $actual_cast['name'];
          $source_dir = $actual_cast['dir'];
          $cast_cache_dir = $this->cacheDirectory . "/" . $cast_name;
          if ($cast_to_update !== null && strtolower($cast_to_update) != strtolower($cast_name)) {
            continue;
          }
 
          // Find media files
          $files = FileSystem::find($source_dir, $actual_cast['filepattern'], '', true);
          $times = array();
          foreach ($files as $file) {
            $times[$file] = filemtime($file);
          }
          asort($times, SORT_DESC);
          $files = array_keys($times);
          unset($times);
 
          // Update cast files
          if (!is_dir("$cast_cache_dir")) {
            FileSystem::mkdir("$cast_cache_dir", 0777);
          }
          $cast_has_changed = $force_update ? true : false;
          $cast_items = array();
          foreach ($files as $file) {
            try {
              $info_file = "$cast_cache_dir/" . trim(str_replace('/', '-', str_replace($source_dir, '', $file)), '-') . '.json';
              $meta_data = null;
              if (is_file($info_file) && filemtime($info_file) >= filemtime($file)) {
                $meta_data = @json_decode(@file_get_contents($info_file), true);
              }
              if (!is_array($meta_data) || empty($meta_data)) {
                $cast_has_changed = true;
                switch (strtolower(FileSystem::getExtension($file))) {
                  case 'mp4':
                  case 'm4v':
                  case 'mov':
                    $meta_data = $this->readVideoFileInfo($file);
                    break;
                  case 'mp3':
                  case 'm4a':
                  case 'aac':
                    $meta_data = $this->readAudioFileInfo($file);
                    break;
                  case 'pdf':
                  case 'epub':
                    $meta_data = $this->readDocumentFileInfo($file);
                    break;
                }
                $meta_data = array_merge(array('title' => '', 'description' => '', 'comment' => '', 'genre' => '', 'created' => 0), $meta_data);
                $meta_data['category'] = 'TV & Film';
                $meta_data['path'] = str_replace($source_dir, '', $file);
                $meta_data['local-file'] = $file;
                $meta_data['created'] = filemtime($file);
                FileSystem::writeFile($info_file, json_encode($meta_data, JSON_FORCE_OBJECT));
                FileSystem::chmod($info_file, 0666);
                FileSystem::touch($info_file, filemtime($file));
 
                Tracer::trace("Update: Cached $info_file", 2);
                if (!empty($meta_data['title']))
                  Tracer::trace("-- Title: {$meta_data['title']}", 2);
                if (!empty($meta_data['description']))
                  Tracer::trace("-- Description: {$meta_data['description']}", 2);
                if (!empty($meta_data['comment']))
                  Tracer::trace("-- Comment: {$meta_data['comment']}", 2);
                if (!empty($meta_data['genre']))
                  Tracer::trace("-- Genre: {$meta_data['genre']}", 2);
                if (!empty($meta_data['created']))
                  Tracer::trace("-- Created on: {$meta_data['created']}", 2);
              }
              $cast_items[] = $meta_data;
            } catch(\Exception $e) {
              Tracer::traceException($e);
            }
          }
 
          if ($cast_has_changed) {
            Tracer::trace("Update: Feed update required for '$cast_name'", 2);            
 
            // Copy icon to cache
            if (is_file($actual_cast['icon-file']) && strtolower(FileSystem::getExtension($actual_cast['icon-file'])) == 'png') {
              FileSystem::copy($actual_cast['icon-file'], $this->cacheDirectory . '/' . $cast_name . '.png');
              FileSystem::chmod($this->cacheDirectory . '/' . $cast_name . '.png', 0666);
            }
 
            // Regenerate and write XML
            $feed = new PodcastFeed(
              rtrim($this->rootUri, '/') . "/" . urlencode($cast_name), empty($actual_cast['title']) ? $actual_cast['name'] : $actual_cast['title'], $actual_cast['description'], $actual_cast['author'], $actual_cast['author-email'], array($source_dir => '') // directory mapping
            );
            $feed->updatePeriodTtlInS = 120; // 2h
            try {
              $feed->setImage($actual_cast['icon']);
            } catch (\Exception $e) {
              Tracer::trace("Update: Icon of '$cast_name' not ok: " . $e->getMessage());
            }
            foreach ($cast_items as $it) {
              set_time_limit(120);
              try {
                $item = $feed->addItem($it['local-file']);
                $item->title = $it['title'];
                $item->description = $it['description'];
                $item->pageLink = $it['comment'];
                $item->category = $it['category'];
                $item->datePublished = $it['created'];
              } catch (\Exception $e) {
                Tracer::traceException($e);
              }
            }
            FileSystem::writeFile($this->cacheDirectory . '/' . $cast_name . '.xml', $feed);
            FileSystem::chmod($this->cacheDirectory . '/' . $cast_name . '.xml', 0666);
          }
        } catch (Exception $e) {
          Tracer::traceException($e);
        }
      }
    } catch (\Exception $e) {
      $this->lockUpdate(false);
      throw $e;
    }
    $this->lockUpdate(false);
  }
 
  /**
   * Cleares the entire cache
   * @return bool
   */
  public function clearCache() {
    $ex = null;
    if (!$this->lockUpdate(true)) {
      throw new Exception("Cache is being processed, try later.");
    }
    foreach (glob($this->cacheDirectory . '/*') as $file) {
      if ($file != $this->logFile) {
        try {
          FileSystem::delete($file);
        } catch (Exception $e) {
          Tracer::trace("Clear cache: Failed to delete '$file': " . $e->getMessage());
          $ex = $e;
        }
      }
    }
    $this->lockUpdate(false);
    if ($ex !== null)
      throw $ex;
  }
 
  /**
   * Sends the list of casts
   * @return void
   */
  protected function sendList() {
    $casts = $this->getCasts();
    header('Content-Type: text/html; Charset=UTF-8', true);
    $template = $this->castListPageView;
    try {
      if (is_callable($template)) {
        $this->castListPageView($casts);
      } else if (is_file($template) && is_readable($template)) {
        @chdir(dirname($template));
        @include $template;
      } else {
        print '<!doctype html><html><head><title>[Hades] autocast</title></head><body><h1 class="podcast-list">Podcastcast list</h1><ul id="podcast-list">' . "\n";
        foreach ($casts as $cast) {
          print "<li><a href=\"" . rtrim($this->rootUri, '/') . "/" . rawurlencode($cast['name']) . "/\">" . htmlspecialchars($cast['title']) . "</a></li>\n";
        }
        print "</ul></body></html>\n";
      }
    } catch (\Exception $e) {
      print "Error in the list view template: $e\n";
    }
  }
 
  /**
   * Sends a podcast XML feed. Auto-updates before sending.
   * @param string $cast
   * @return void
   */
  protected function sendFeed($cast) {
    $this->checkUpdate($cast);
    foreach (glob($this->cacheDirectory . '/*.xml') as $file) {
      if (stripos($cast, FileSystem::getFileNameWithoutExtension(FileSystem::getBasename($file))) !== false) {
        @header('Content-Type:  application/rss+xml; Charset=UTF-8');
        print FileSystem::readFile($file);
        return;
      }
    }
    header('Content-Type: text/plain; Charset=UTF-8', true, 404);
    print "404 Not found\n";
  }
 
  /**
   * Sends a podcast episode media file
   * @param string $cast
   * @param string $path
   */
  protected function sendItem($cast, $path) {
    $dir = $this->getSource($cast);
    $file = "$dir/" . trim($path, '/ ');
    if (!$dir) {
      header('Content-Type: text/plain; Charset=UTF-8', true, 404);
      print "Podcast not found\n";
    } else if (!is_file($file)) {
      header('Content-Type: text/plain; Charset=UTF-8', true, 404);
      print "Podcast media file not found\n";
    } else if (!is_readable($file)) {
      header('Content-Type: text/plain; Charset=UTF-8', true, 501);
      print "Podcast media file not readable\n";
    } else {
      Tracer::disable();
      $dl = new ResourceFile($file);
      $dl->download();
      exit();
    }
  }
 
  /**
   * Sends a cast icon (png file) or if not existing the global autocast icon.
   * @param string $cast
   */
  protected function sendIcon($cast) {
    $dl = $this->defaultIcon;
    foreach (@glob($this->cacheDirectory . '/*.png') as $file) {
      if (stripos($file, "$cast.png") !== false) {
        $dl = $file;
        break;
      }
    }
    if (!is_file($dl)) {
      header('Content-Type: text/plain; Charset=UTF-8', true, 404);
      print "Podcast icon not found.";
    } else {
      $rc = new ResourceFile($dl);
      $rc->download();
      exit(0);
    }
  }
 
  /**
   * Sends the list of episodes in JSON
   * @param string $cast
   */
  protected function sendEpisodeList($cast) {
    print @json_encode($this->getEpisodeList($cast));
  }
 
  /**
   * Main function
   * @return void
   */
  public function main() {
    try {
      Tracer::setLevel($this->logLevel);
      Tracer::appendProtocol(false);
      header('Content-Type: text/plain; Charset=UTF-8', true);
      Tracer::trace("Request: {$_SERVER['REQUEST_URI']}\n");
 
      $request = $_SERVER['REQUEST_URI'];
 
      if (strpos($request, '?') !== false) {
        $request = explode('?', $request, 2);
        $request = @reset($request);
      }
      $request = rtrim($request, '/');
      if (stripos($request, $_SERVER['SCRIPT_NAME']) === 0) {
        if(strlen($request) == strlen($_SERVER['SCRIPT_NAME'])) {
          $request = '';
        } else {
          $request = substr($request, strlen($_SERVER['SCRIPT_NAME']));
        }        
      } else if (stripos($request, dirname($_SERVER['SCRIPT_NAME'])) === 0) {
        if(strlen($request) == strlen(dirname($_SERVER['SCRIPT_NAME']))) {
          $request = '';
        } else {
          $request = substr($request, strlen(dirname($_SERVER['SCRIPT_NAME'])));
        }        
      }      
      $request = array_filter(explode('/', urldecode(ltrim($request, '/')), 2));
 
      if (empty($request)) {
        if (!empty($_GET)) {
          reset($_GET);
          header('Content-Type: text/plain; Charset=UTF-8', true);
          $command = strtolower(trim(key($_GET)));
          try {
            switch ($command) {
              case 'log':
                print @file_get_contents($this->logFile);
                break;
              case 'log-reset':
              case 'logreset':
              case 'reset-log':
              case 'resetlog':
                @file_put_contents($this->logFile, '');
                print "Log file deleted.\n";
                break;
              case 'clear':
              case "clear-cache":
                $this->clearCache();
                print "Cache cleared\n";
                break;
              case 'reset':
              case 'rebuild':
                $this->clearCache();
              case 'update':
              case 'build':
                @file_put_contents($this->logFile, '');
                $this->update();
                break;
              default:
                header('Not found', true, 404);
                print "Command '$command' not found.\n";
            }
          } catch (\Exception $e) {
            print $e->getMessage() . "\n";
          }
        } else {
          $this->sendList();
        }
      } else if (count($request) == 1 && stripos(reset($request), '.png') !== false) {
        $this->sendIcon(FileSystem::getFileNameWithoutExtension(reset($request)));
      } else if (count($request) == 1) {
        $this->sendFeed(reset($request));
      } else {
        if (trim(end($request), ' /') == 'episodes') {
          $this->sendEpisodeList(reset($request));
        } else {
          $this->sendItem(reset($request), end($request));
        }
      }
    } catch (\Exception $e) {
      Tracer::traceException($e);
      header('Content-Type: text/plain; Charset=UTF-8', true);
      print "Ooops, that should not have happened!\n\nWe've got an exception here: $e\n";
    }
    try {
      $trace = trim(Tracer::disable(true), "\n\r\t ");
      if (!empty($trace)) {
        @file_put_contents($this->logFile, $trace . "\n", FILE_APPEND | LOCK_EX);
        try {
          FileSystem::chmod($this->logFile, 0666);
        } catch (\Exception $e) {
        }
      }
    } catch (\Exception $e) {
      ;
    }
  }
}