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
andclass PodcastFeed
, which generate a XML podcast feed as defined in the standard. They are based onclass Rss2FeedItem
andRss2Feed
.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
undclass PodcastFeed
, die den XML feed entsprechend dem Standard erstellen und aufclass Rss2FeedItem
undRss2Feed
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
andlist.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. TheAutoPodcast
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 theAutoPodcast
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 aautocast
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 isdead-gentlemen
with the line/var/www/workdir/media/dead-gentlemen
. That means the cast XML will be found underhttp://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
andlist.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 Unterverzeichnisautocast
enthalten, in dem Metadaten über den Podcast stehen. Das sind Textdateien mit definiertem Namen wietitle
,description
usw., die (mit Ausnahme vondescription
aus einer Zeile bestehen. DieAutoPodcast
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 beschriebenenautocast
-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 Dateidead-gentlemen
da, in der die Zeile/var/www/workdir/media/dead-gentlemen
steht. Damit sucht das System in unserem Mediafolder, und der Podcast ist unterhttp://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('&', '"', ''', '<', '>'), $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) {
;
}
}
}