parameters[$var_name]))
return $this->parameters[$var_name];
break;
case 'set':
$this->parameters[$var_name] = $params[0];
break;
default:
throw new Exception("Unknown method '" . $method . "' called on " . __CLASS__);
}
}
}
interface Podcast_Helper {
public function id();
public function appendToChannel(DOMElement $d, DOMDocument $doc);
public function appendToItem(DOMElement $d, DOMDocument $doc, RSS_Item $item);
public function addNamespaceTo(DOMElement $d, DOMDocument $doc);
}
/**
* Uses external getID3 lib to analyze MP3 files.
*
*/
class getID3_Podcast_Helper implements Podcast_Helper {
public function id()
{
return get_class($this);
}
static $AUTO_SAVE_COVER_ART = false;
public function appendToChannel(DOMElement $d, DOMDocument $doc) { /* nothing */ }
public function addNamespaceTo(DOMElement $d, DOMDocument $doc) { /* nothing */ }
/**
* Fills in a bunch of info on the Item by using getid3->Analyze()
*/
public function appendToItem(DOMElement $d, DOMDocument $doc, RSS_Item $item)
{
if($item instanceof Media_RSS_Item && !$item->getAnalyzed())
{
$getid3 = new getID3();
$getid3->option_tag_lyrics3 = false;
$getid3->option_tag_apetag = false;
$getid3->option_tags_html = false;
// $getid3->option_save_attachments = true; // TODO: set this to a path
$getid3->encoding = 'UTF-8';
try
{
$info = $getid3->analyze($item->getFilename());
$getid3->CopyTagsToComments($info);
}
catch(getid3_exception $e)
{
// MP3 couldn't be analyzed.
return;
}
unset($this->getid3);
if(!empty($info['comments']))
{
if(!empty($info['comments']['title'][0]))
$item->setID3Title( $info['comments']['title'][0] );
if(!empty($info['comments']['artist'][0]))
$item->setID3Artist( $info['comments']['artist'][0] );
if(!empty($info['comments']['album'][0]))
$item->setID3Album( $info['comments']['album'][0] );
if(!empty($info['comments']['comment'][0]))
$item->setID3Comment( $info['comments']['comment'][0] );
if(!empty($info['comments']['track_number'][0]))
$item->setID3Track( $info['comments']['track_number'][0] );
if(!empty($info['comments']['part_of_a_set'][0]))
$item->setID3PartOfASet( $info['comments']['part_of_a_set'][0] );
if(self::$AUTO_SAVE_COVER_ART)
{
if(!empty($info['comments']['picture'][0]))
{
$item->saveImage(
$info['comments']['picture'][0]['image_mime'],
$info['comments']['picture'][0]['data']
);
}
}
}
if(!empty($info['playtime_string']))
$item->setDuration( $info['playtime_string'] );
else
$item->setDuration('0:00');
$item->setAnalyzed(true);
}
}
}
/**
* Loads metadata from a cache file, if possible.
*
*/
class Caching_getID3_Podcast_Helper implements Podcast_Helper {
public function id()
{
return get_class($this);
}
protected $wrapped_helper;
protected $cache_dir;
public function __construct($cache_dir, getID3_Podcast_Helper $getID3_podcast_helper) {
$this->cache_dir = $cache_dir;
$this->wrapped_helper = $getID3_podcast_helper;
}
public function appendToChannel(DOMElement $d, DOMDocument $doc) {
return $this->wrapped_helper->appendToChannel($d, $doc);
}
public function addNamespaceTo(DOMElement $d, DOMDocument $doc) {
return $this->wrapped_helper->appendToChannel($d, $doc);
}
public function appendToItem(DOMElement $d, DOMDocument $doc, RSS_Item $item)
{
if($item instanceof Media_RSS_Item && $item instanceof Serializable && !$item->getAnalyzed())
{
$cache_filename = $this->getCacheFileName($this->cache_dir, $item);
if($this->loadFromCache($cache_filename, $item)) {
return;
}
$this->wrapped_helper->appendToItem($d, $doc, $item);
$this->saveToCache($cache_filename, $item);
}
}
protected function getCacheFileName($cache_dir, Media_RSS_Item $item) {
return $cache_dir . '/' . md5($item->getFilename()) . '__' . basename($item->getFilename()) . '__data';
}
protected function loadFromCache($filename, Media_RSS_Item $item) {
if(!file_exists($filename) || !is_readable($filename))
return false; // no or unreadable cache file
if(filemtime($filename) < $item->getModificationTime())
return false; // cache file is older than file, so probably stale
try
{
$item->unserialize(file_get_contents($filename));
return true;
}
catch(SerializationException $e)
{
// wrong serialization version. Should re-generate.
return false;
}
}
protected function saveToCache($filename, Serializable $item) {
if(is_writable(dirname($filename)))
file_put_contents($filename, $item->serialize());
}
}
class Atom_Podcast_Helper extends GetterSetter implements Podcast_Helper {
public function id()
{
return get_class($this);
}
protected $self_link;
public function __construct() { }
public function getNSURI()
{
return 'http://www.w3.org/2005/Atom';
}
public function addNamespaceTo(DOMElement $d, DOMDocument $doc)
{
$d->appendChild( $doc->createAttribute( 'xmlns:atom' ) )
->appendChild( new DOMText( $this->getNSURI() ) );
}
public function appendToChannel(DOMElement $channel, DOMDocument $doc)
{
foreach ($this->parameters as $name => $val)
{
$channel->appendChild( $doc->createElement('atom:' . $name) )
->appendChild( new DOMText($val) );
}
if(!empty($this->self_link))
{
$linkNode = $channel->appendChild( $doc->createElement('atom:link') );
$linkNode->setAttribute('href', $this->self_link);
$linkNode->setAttribute('rel', 'self');
$linkNode->setAttribute('type', ATOM_TYPE);
}
}
public function appendToItem(DOMElement $item_element, DOMDocument $doc, RSS_Item $item)
{
}
public function setSelfLink($link)
{
$this->self_link = $link;
}
}
class iTunes_Podcast_Helper extends GetterSetter implements Podcast_Helper {
public function id()
{
return get_class($this);
}
static $ITUNES_SUBTITLE_SUFFIX = '';
static $ITUNES_TYPE = "episodic";
protected $owner_name, $owner_email, $image_href, $explicit;
protected $categories = array();
public function __construct() { }
public function getNSURI()
{
return 'http://www.itunes.com/dtds/podcast-1.0.dtd';
}
public function addNamespaceTo(DOMElement $d, DOMDocument $doc)
{
$d->appendChild( $doc->createAttribute( 'xmlns:itunes' ) )
->appendChild( new DOMText( $this->getNSURI() ) );
}
public function appendToChannel(DOMElement $channel, DOMDocument $doc)
{
foreach ($this->parameters as $name => $val)
{
$channel->appendChild( $doc->createElement('itunes:' . $name) )
->appendChild( new DOMText($val) );
}
foreach ($this->categories as $category => $subcats)
{
$this->appendCategory($category, $subcats, $channel, $doc);
}
if(!empty($this->owner_name) || !empty($this->owner_email))
{
$owner = $channel->appendChild( $doc->createElement('itunes:owner') );
if(!empty($this->owner_name))
{
$owner->appendChild( $doc->createElement('itunes:name') )
->appendChild( new DOMText( $this->owner_name ) );
}
if(!empty($this->owner_email))
{
$owner->appendChild( $doc->createElement('itunes:email') )
->appendChild( new DOMText( $this->owner_email ) );
}
}
if(!empty($this->explicit))
{
$channel->appendChild( $doc->createElement('itunes:explicit') )
->appendChild( new DOMText( $this->explicit ) );
}
if(!empty($this->image_href))
{
$channel->appendChild( $doc->createElement('itunes:image') )
->setAttribute('href', $this->image_href);
}
if(strlen(iTunes_Podcast_Helper::$ITUNES_TYPE))
{
$channel->appendChild( $doc->createElement('itunes:type') )
->appendChild( new DOMText( iTunes_Podcast_Helper::$ITUNES_TYPE == "serial" ? "serial" : "episodic" ) );
}
}
public function appendToItem(DOMElement $item_element, DOMDocument $doc, RSS_Item $item)
{
/*
* John Doe
* 7:04
* A short primer on table spices
* This week we talk about salt and pepper shakers,
* [...] Come and join the party!
* salt, pepper, shaker, exciting
*/
$elements = array(
'author' => $item->getID3Artist(),
'duration' => $item->getDuration(),
//'keywords' => 'not supported yet.'
);
// iTunes summary is excluded if it's empty, because the default is to
// duplicate what's in the "description field", but iTunes will fall back
// to showing the if there is no summary anyway.
$itunes_summary = $item->getSummary();
if($itunes_summary !== '')
{
$elements['summary'] = $itunes_summary;
}
// iTunes subtitle is excluded if it's empty. iTunes will fall back to
// the itunes:summary or description if there's no subtitle.
$itunes_subtitle = $item->getSubtitle();
if($itunes_subtitle !== '')
{
$elements['subtitle'] = $itunes_subtitle . iTunes_Podcast_Helper::$ITUNES_SUBTITLE_SUFFIX;
}
if(iTunes_Podcast_Helper::$ITUNES_TYPE == "serial")
{
$episode = $item->getEpisode();
if($episode !== '')
{
$elements['episode'] = $episode;
}
$season = $item->getSeason();
if($season !== '')
{
$elements['season'] = $season;
}
}
foreach($elements as $key => $val)
if(!empty($val))
$item_element->appendChild( $doc->createElement('itunes:' . $key) )
->appendChild( new DOMText($val) );
// Look to see if there is a item specific image and include it.
$item_image = $item->getImage();
if(!empty($item_image))
{
$item_element->appendChild( $doc->createElement('itunes:image') )
->setAttribute('href', $item_image);
}
}
public function appendCategory($category, $subcats, DOMElement $e, DOMDocument $doc)
{
$e->appendChild( $element = $doc->createElement('itunes:category') )
->setAttribute('text', $category);
if(is_array($subcats))
foreach($subcats as $subcategory => $subsubcats)
$this->appendCategory($subcategory, $subsubcats, $element, $doc);
}
/**
* Takes a category specification string and arrayifies it.
* e.g.
* 'Music, Technology > Gadgets '
* becomes
* array( 'Music' => true, 'Technology' => array( 'Gadgets' ) );
* @param string $category_string
*/
public function addCategories($category_string) {
$categories = array();
foreach(explode(',', $category_string) as $top_level_category)
{
$sub_categories = explode('>', $top_level_category);
$top_level_category = trim( array_shift($sub_categories) );
if('' != $top_level_category)
{
if(empty($sub_categories))
{
$categories[$top_level_category] = true;
}
else
{
foreach($sub_categories as $sub_category)
{
$sub_category = trim($sub_category);
if('' != $sub_category)
{
$categories[$top_level_category][$sub_category] = true;
}
}
}
}
}
$this->categories = $categories;
}
public function setOwnerName($name)
{
$this->owner_name = $name;
}
public function setOwnerEmail($email)
{
$this->owner_email = $email;
}
public function setImage($href)
{
$this->image_href = $href;
}
public function setExplicit($explicit)
{
$this->explicit = $explicit;
}
}
class RSS_Item extends GetterSetter {
protected $helpers = array();
public function __construct() { }
public function appendToChannel(DOMElement $channel, DOMDocument $doc)
{
$item_element = $channel->appendChild( new DOMElement('item') );
foreach($this->helpers as $helper)
{
// do helpers first; they may fill in the stuff we add down below.
$helper->appendToItem($item_element, $doc, $this);
}
$item_elements = array(
'title' => $this->getTitle(),
'link' => $this->getLink(),
'pubDate' => $this->getPubDate()
);
$cdata_item_elements = array(
'description' => $this->getDescription()
);
if(empty($item_elements['title']))
$item_elements['title'] = '(untitled)';
foreach($item_elements as $name => $val)
{
$item_element->appendChild( new DOMElement($name) )
->appendChild(new DOMText($val));
}
foreach($cdata_item_elements as $name => $val)
{
if($name == 'description')
if(!defined('DESCRIPTION_HTML'))
$val = htmlspecialchars((string)$val);
$item_element->appendChild( new DOMElement($name) )
->appendChild( $doc->createCDATASection(
// reintroduce newlines as .
nl2br($val)
) );
}
// Look to see if there is an item specific image and include it.
$item_image = $this->getImage();
if(!empty($item_image))
{
$item_element->appendChild( $doc->createElement('image') )
->appendChild(new DOMText($item_image));
}
$enclosure = $item_element->appendChild(new DOMElement('enclosure'));
$enclosure->setAttribute('url', $this->getLink());
$enclosure->setAttribute('length', $this->getLength());
$enclosure->setAttribute('type', $this->getType());
}
public function addHelper(Podcast_Helper $helper)
{
$this->helpers[$helper->id()] = $helper;
return $helper;
}
}
class RSS_File_Item extends RSS_Item {
static $FILES_URL = '';
static $FILES_DIR = '';
public function __construct($filename)
{
$this->setFilename($filename);
$this->setLinkFromFilename($filename);
parent::__construct();
}
public function setLinkFromFilename($filename)
{
$this->setLink($this->filenameToUrl($filename));
}
public function setFilename($filename)
{
parent::setFilename($filename);
$pos = strrpos($this->getFilename(), '.');
if($pos !== false)
{
$this->setExtension(substr($filename, $pos + 1));
}
else
{
$this->setExtension('');
}
}
protected function filenameToUrl($filename)
{
return rtrim(RSS_File_Item::$FILES_URL, '/') . '/' . str_replace('%2F', '/', rawurlencode($this->stripBasePath($filename)));
}
protected function stripBasePath($filename)
{
if(strlen(RSS_File_Item::$FILES_DIR) && strpos($filename, RSS_File_Item::$FILES_DIR) === 0)
{
$filename = ltrim(substr($filename, strlen(RSS_File_Item::$FILES_DIR)), '/');
}
return $filename;
}
/**
* RSS_File_Items will always have a title (the filename) so, in subclasses, to check if one was set manually
* you must call this method, not just check if parent::getTitle() is empty.
*/
protected function hasOverridenTitle()
{
return !!parent::getTitle();
}
/**
* Default title for an RSS Item is its filename.
* Subclasses (Such as Media_RSS_Item, MP3_RSS_Item, etc) override this using e.g. ID3 tags.
*
* @return string
*/
public function getTitle()
{
$overridden_title = parent::getTitle();
if($overridden_title)
{
return $overridden_title;
}
return basename($this->getFilename());
}
public function getType()
{
$overridden_type = parent::getType();
if($overridden_type)
{
return $overridden_type;
}
return 'application/octet-stream';
}
/**
* Place a file with the same name but .txt instead of . and the contents will be used
* as the summary for the item in the podcast.
*
* The summary appears in iTunes when you click the 'more info' icon, and can be
* multiple lines long.
*
* @return String the summary, or null if there's no summary file
*/
public function getSummary()
{
$overridden_summary = parent::getSummary();
if($overridden_summary)
{
return $overridden_summary;
}
$summary_file_name = dirname($this->getFilename()) . '/' . basename($this->getFilename(), '.' . $this->getExtension()) . '.txt';
if(file_exists( $summary_file_name ))
return file_get_contents($summary_file_name);
}
/**
* Place a file with the same name but _subtitle.txt instead of . and the contents will be used
* as the subtitle for the item in the podcast.
*
* The subtitle appears inline with the podcast item in iTunes, and has a 'more info' icon next
* to it. It should be a single line.
*
* @return String the subtitle, or null if there's no subtitle file
*/
public function getSubtitle()
{
$overridden_subtitle = parent::getSubtitle();
if($overridden_subtitle)
{
return $overridden_subtitle;
}
$summary_file_name = dirname($this->getFilename()) . '/' . basename($this->getFilename(), '.' . $this->getExtension()) . '_subtitle.txt';
if(file_exists( $summary_file_name ))
return file_get_contents($summary_file_name);
}
protected function getImageFilename($type)
{
$item_file_name = $this->getFilename();
$ext_length = strlen($this->getExtension());
if($ext_length == 0)
{
$image_file_name = rtrim($item_file_name, '.') . '.' . $type;
}
else {
$item_file_name_length = strlen($this->getFilename());
$image_file_name = rtrim(substr($item_file_name, 0, $item_file_name_length - $ext_length), '.') . '.' . $type;
}
return $image_file_name;
}
/**
* Place a file with the same name but .jpg or .png instead of . and the contents will be used
* as the cover art for the item in the podcast.
*
* @return String the filename of the cover art or null if there's no cover art file
*/
public function getImage()
{
$overridden_image = parent::getImage();
if($overridden_image)
{
return $overridden_image;
}
$image_file_name = $this->getImageFilename('png');
if(file_exists( $image_file_name ))
return $this->filenameToUrl($image_file_name);
$image_file_name = $this->getImageFilename('jpg');
if(file_exists( $image_file_name ))
return $this->filenameToUrl($image_file_name);
}
public function saveImage($mime_type, $data)
{
if(file_exists($this->getImageFilename('jpg')) || file_exists($this->getImageFilename('png')))
{
// don't overwrite image which already exists, even if it's of the wrong type.
return;
}
switch($mime_type) {
case 'image/jpeg':
$filename = $this->getImageFilename('jpg');
if(is_writable(dirname($filename)))
file_put_contents($filename, $data);
break;
case 'image/png':
$filename = $this->getImageFilename('png');
if(is_writable(dirname($filename)))
file_put_contents($filename, $data);
break;
}
}
public function getFileSize()
{
return filesize($this->getFilename());
}
public function getFileTimestamp()
{
return filemtime($this->getFilename());
}
public function getModificationTime()
{
$mtimes = array(
$this->getFileTimestamp()
);
$common_prefix = dirname($this->getFilename()) . '/' . basename($this->getFilename(), '.' . $this->getExtension());
foreach(array(
$this->getImageFilename('jpg'),
$this->getImageFilename('png'),
$common_prefix . '.txt',
$common_prefix . '_subtitle.txt'
) as $f)
{
if(file_exists($f))
{
$mtimes[] = filemtime($f);
}
}
return max($mtimes);
}
public function getTotalFileSize()
{
$sizes = array(
$this->getFileSize()
);
$common_prefix = dirname($this->getFilename()) . '/' . basename($this->getFilename(), '.' . $this->getExtension());
foreach(array(
$this->getImageFilename('jpg'),
$this->getImageFilename('png'),
$common_prefix . '.txt',
$common_prefix . '_subtitle.txt'
) as $f)
{
if(file_exists($f))
{
$sizes[] = filesize($f);
}
}
return array_sum($sizes);
}
}
class Media_RSS_Item extends RSS_File_Item implements Serializable {
static $LONG_TITLES = false;
static $DESCRIPTION_SOURCE = 'comment';
public function __construct($filename)
{
parent::__construct($filename);
$this->setFromMediaFile();
}
public function setFromMediaFile()
{
// don't do any heavy-lifting here as this is called by the constructor, which
// is called once for every media file in the dir (not just the ITEM_COUNT in the cast)
// TODO: this will go slightly faster if we don't do these syscalls here
$this->setLength($this->getFileSize());
$this->setPubDate(date('r', $this->getFileTimestamp()));
}
/**
* The title of the file item as it should appear in the podcast.
*
* Media_RSS_Items will derive this from the ID3 (or other) tags in the file, if available.
*
* The default title is just the "Title" tag, unless LONG_TITLES is set, in which case
* it's "Album - Artist - Title" using the applicable tags.
*
* If these are all blank, it falls back to the filename so that you can at least see what is
* what in the feed.
*
* @see RSS_File_Item::getTitle()
*
* @return string
*/
public function getTitle()
{
if($this->hasOverridenTitle())
{
return parent::getTitle();
}
$title_parts = array();
if(self::$LONG_TITLES)
{
if($this->getID3Album()) $title_parts[] = $this->getID3Album();
if($this->getID3Artist()) $title_parts[] = $this->getID3Artist();
}
if($this->getID3Title())
{
$title_parts[] = $this->getID3Title();
}
if(!empty($title_parts))
{
return implode(' - ', $title_parts);
}
return parent::getTitle();
}
public function getDescription()
{
$overridden_description = parent::getDescription();
if($overridden_description)
{
return $overridden_description;
}
// The default value is "comment". dir2cast prior to v1.19
// used value "file", so it's here for backward compatibility
if(self::$DESCRIPTION_SOURCE == 'summary' || self::$DESCRIPTION_SOURCE == 'file')
return parent::getSummary(); // call to parent because otherwise we could co-recurse.
return $this->getID3Comment();
}
public function getSummary()
{
$summary = parent::getSummary();
if(!$summary)
{
// use description as summary if there's no file-based override
$summary = $this->getDescription();
}
return $summary;
}
public function getSubtitle()
{
$subtitle = parent::getSubtitle();
if(!$subtitle && !self::$LONG_TITLES)
{
// use artist as subtitle if there's no file-based override
// but not if LONG_TITLES is set (as it's already in the title)
$subtitle = $this->getID3Artist();
}
return $subtitle;
}
public function getEpisode()
{
$episode = parent::getEpisode();
if(!$episode)
{
// use track tag as season if there's no override
$episode = $this->getID3Track();
}
return $episode;
}
public function getSeason()
{
$season = parent::getSeason();
if(!$season)
{
// use part_of_a_set tag as season if there's no override
$season = $this->getID3PartOfASet();
}
return $season;
}
/**
* Version number used in the saved cache files. If the used fields change, increment this number.
* @var integer
*/
const SERIAL_VERSION = 1;
public function __serialize()
{
$this->setSerialVersion(self::SERIAL_VERSION);
$serialized_parameters = $this->parameters;
// these are all set from the filesystem metadata, not the file content.
unset($serialized_parameters['length']);
unset($serialized_parameters['pubDate']);
unset($serialized_parameters['filename']);
unset($serialized_parameters['extension']);
unset($serialized_parameters['link']);
return $serialized_parameters;
}
public function serialize()
{
return serialize($this->__serialize());
}
public function __unserialize($serialized)
{
if($serialized['serialVersion'] != self::SERIAL_VERSION)
throw new SerializationException("Wrong serialized version");
// keep properties we've already set. This should make cache files transferable
// whilst still gaining a speed-up over ID3-reading.
$this->parameters = array_merge($serialized, $this->parameters);
}
public function unserialize($serialized)
{
$this->__unserialize(unserialize($serialized));
}
}
class MP3_RSS_Item extends Media_RSS_Item
{
public function __construct($filename)
{
parent::__construct($filename);
$this->setType('audio/mpeg');
}
}
class M4A_RSS_Item extends Media_RSS_Item
{
public function __construct($filename)
{
parent::__construct($filename);
$this->setType('audio/mp4');
}
}
class MP4_RSS_Item extends Media_RSS_Item
{
public function __construct($filename)
{
parent::__construct($filename);
$this->setType('video/mp4');
}
}
abstract class Podcast extends GetterSetter
{
protected $items = array();
protected $helpers = array();
public function addHelper(Podcast_Helper $helper)
{
$this->helpers[$helper->id()] = $helper;
// attach helper to items already added.
// new items will have the helper attached when they are added.
foreach($this->items as $item)
$item->addHelper($helper);
return $helper;
}
// public function getNSURI()
// {
// return 'http://backend.userland.com/RSS2';
// }
public function http_headers($content_length)
{
// The correct content type is application/rss+xml; however, the de-facto standard is now text/xml.
// See https://stackoverflow.com/questions/595616/what-is-the-correct-mime-type-to-use-for-an-rss-feed
header('Content-type: text/xml; charset=UTF-8');
header('Content-length: ' . $content_length);
header('Last-modified: ' . $this->getLastBuildDate());
}
/**
* Generates and returns the podcast RSS
*
* @return String the PodCast RSS
*/
public function generate()
{
$this->pre_generate();
$this->setLastBuildDate(date('r'));
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->formatOutput = true;
$rss = $doc->createElement('rss');
$doc->appendChild($rss);
$rss->setAttribute('version', '2.0');
// we do not show the default xmlns. Seems to break the validator.
// $rss->appendChild( $doc->createAttribute( 'xmlns' ) )
// ->appendChild( new DOMText( $this->getNSURI() ) );
foreach($this->helpers as $helper)
$helper->addNamespaceTo($rss, $doc);
// the channel
$channel = $rss->appendChild(new DOMElement('channel'));
$channel_elements = array(
'title' => $this->getTitle(),
'link' => $this->getLink(),
'description' => $this->getDescription(),
'lastBuildDate' => $this->getLastBuildDate(),
'language' => $this->getLanguage(),
'copyright' => str_replace('%YEAR%', date('Y'), (string)$this->getCopyright()),
'generator' => $this->getGenerator(),
'webMaster' => $this->getWebMaster(),
'ttl' => $this->getTtl()
);
foreach($channel_elements as $name => $val)
{
$channel->appendChild( new DOMElement($name) )
->appendChild(new DOMText((string)$val));
}
$this->appendImage($channel);
foreach($this->helpers as $helper)
{
$helper->appendToChannel($channel, $doc);
}
// channel item list
foreach($this->getItems() as $item)
{
/* @var $item RSS_Item */
$item->appendToChannel($channel, $doc);
}
$this->post_generate($doc);
$doc->normalizeDocument();
// see http://validator.w3.org/feed/docs/warning/CharacterData.html
return str_replace(
array('&', '<', '>'),
array('&', '<', '>'),
utf8_for_xml($doc->saveXML())
);
}
public function addRssItem(RSS_Item $item)
{
$this->items[] = $item;
// attach helpers to the new item.
// new helpers will be attached if they are added later.
foreach($this->helpers as $helper)
$item->addHelper($helper);
}
/**
* @return array of RSS_Item
*/
public function getItems()
{
return $this->items;
}
protected function appendImage(DOMElement $channel)
{
$image_url = $this->getImage();
if(strlen((string)$image_url))
{
$image = $channel->appendChild( new DOMElement('image'));
$image->appendChild( new DOMElement('url') )
->appendChild(new DOMText($image_url));
$image->appendChild( new DOMElement('link') )
->appendChild(new DOMText($this->getLink()));
$image->appendChild( new DOMElement('title') )
->appendChild(new DOMText($this->getTitle()));
$channel->appendChild($image);
}
}
protected function pre_generate() { }
protected function post_generate(DOMDocument $doc) { }
}
class Dir_Podcast extends Podcast
{
static $EMPTY_PODCAST_IS_ERROR = false;
static $RECURSIVE_DIRECTORY_ITERATOR = false;
static $ITEM_COUNT = 10;
static $MIN_FILE_AGE = 0;
static $DEBUG = false;
protected $source_dir;
protected $scanned = false;
protected $unsorted_items = array();
protected $max_mtime = 0;
protected $item_hash_list = array();
protected $item_hash;
protected $clock_offset = 0;
/**
* Constructor
*
* @param string $source_dir
*/
public function __construct($source_dir)
{
$this->source_dir = $source_dir;
}
/**
* Looks for all files in the media path and adds them to the podcast,
* tracking the most recently modified date in ->max_mtime
*/
protected function scan()
{
if(!$this->scanned)
{
self::$DEBUG && print("Scanning…\n");
$this->pre_scan();
// scan the dir
if(self::$RECURSIVE_DIRECTORY_ITERATOR)
{
$di = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->source_dir),
RecursiveIteratorIterator::SELF_FIRST
);
}
else
{
$di = new DirectoryIterator($this->source_dir);
}
$item_count = 0;
foreach($di as $file)
{
$filepath = $file->getPath() . '/' . $file->getFileName();
self::$DEBUG && print("Considering {$filepath}…\n");
$item_count = $this->addItem($filepath);
}
self::$DEBUG && print("$item_count items added.\n");
if(self::$EMPTY_PODCAST_IS_ERROR && 0 == $item_count)
{
http_response_code(404);
throw new Exception("No content yet.");
}
$this->calculateItemHash();
$this->scanned = true;
$this->post_scan();
$this->sort();
}
}
/**
* Adds file to ->unsorted_items, and updates ->max_mtime, if it is of a supported type
*
* @param string $filename
*/
public function addItem($filename)
{
$pos = strrpos($filename, '.');
if(false === $pos)
$file_ext = '';
else
$file_ext = strtolower(substr($filename, $pos + 1));
switch($file_ext)
{
case 'mp3':
$this->addRssFileItem(new MP3_RSS_Item($filename));
break;
case 'm4a':
case 'm4b':
$this->addRssFileItem(new M4A_RSS_Item($filename));
break;
case 'mp4':
$this->addRssFileItem(new MP4_RSS_Item($filename));
break;
default:
// no other file types are considered for the podcast
}
return count($this->unsorted_items);
}
public function updateMaxMtime($date, $filename)
{
if($date > $this->max_mtime)
{
self::$DEBUG && print("mtime ceiling {$this->max_mtime} ➡ {$date} (now: " . time() . ") from $filename\n");
$this->max_mtime = $date;
}
}
public function getMaxMtime()
{
return $this->max_mtime;
}
public function calculateItemHash()
{
sort($this->item_hash_list);
$this->item_hash = md5(implode("\n", $this->item_hash_list));
}
public function getItemHash()
{
return $this->item_hash;
}
public function setClockOffset($offset)
{
$this->clock_offset = $offset;
}
/**
* Adds file to ->unsorted_items, and updates ->max_mtime
*
* @param RSS_File_Item $the_item
*/
protected function addRssFileItem(RSS_File_Item $the_item)
{
// skip 0-length files. getID3 chokes on them and listeners dislike them
if($the_item->getFileSize())
{
$filemtime_media_only = $the_item->getFileTimestamp();
if((self::$MIN_FILE_AGE > 0) && $filemtime_media_only > (time() - self::$MIN_FILE_AGE))
{
// don't add files which are so new that they may still be being uploaded
return;
}
$filemtime_inclusive = $the_item->getModificationTime();
// one array per mtime, just in case several MP3s share the same mtime.
$this->updateMaxMtime($filemtime_inclusive, $the_item->getFilename());
$this->unsorted_items[$filemtime_media_only][] = $the_item;
$hashlist_mtime = $filemtime_inclusive + $this->clock_offset; // clock offset is just used in testing.
$this->item_hash_list[] = "{$hashlist_mtime}:{$the_item->getTotalFileSize()}";
}
}
protected function pre_generate()
{
$this->scan();
// Add helpers here, NOT during scan().
// scan() is also used just to get mtimes to see if we need to regenerate the feed.
foreach($this->helpers as $helper)
foreach($this->items as $the_item)
$the_item->addHelper($helper);
}
protected function sort() {
krsort($this->unsorted_items); // newest first
$this->items = array();
$i = 0;
foreach($this->unsorted_items as $item_list)
{
foreach($item_list as $item)
{
$this->items[$i++] = $item;
if($i >= self::$ITEM_COUNT)
break 2;
}
}
unset($this->unsorted_items);
}
protected function pre_scan() { }
protected function post_scan() { }
}
/**
* Podcast with cached output. The cache file will be created and a file lock
* obtained at object construction time. The lock will be released in the object's
* destructor.
*/
class Cached_Dir_Podcast extends Dir_Podcast
{
protected $temp_dir;
protected $temp_file;
protected $item_hash_file;
protected $cache_date;
protected $serve_from_cache;
static $MIN_CACHE_TIME = 5; // seconds
/**
* Constructor
*
* After constructing, you should call ->init() to make use of the whole-feed cache
*
* @param string $source_dir
* @param string $temp_dir
*/
public function __construct($source_dir, $temp_dir)
{
$this->temp_dir = $temp_dir;
$safe_source_name = preg_replace('/[^\w]/', '_', dirname($source_dir) . '/' . basename($source_dir) );
// something unique, safe, stable and easily identifiable
$this->temp_file = rtrim($temp_dir, '/') . '/' . md5($source_dir) . '_' . $safe_source_name . '.xml';
$this->item_hash_file = rtrim($temp_dir, '/') . '/' . md5($source_dir) . '_' . $safe_source_name . '__item_hash.txt';
parent::__construct($source_dir);
}
/**
* Initialises the cache file
*/
public function init()
{
if($this->isCached())
{
self::$DEBUG && print("Found cache file\n");
$this->serve_from_cache = true;
$cache_date = filemtime($this->temp_file);
// if the cache file is quite new, don't bother regenerating.
if( $cache_date < time() - self::$MIN_CACHE_TIME )
{
self::$DEBUG && print("Cache file is older than " . self::$MIN_CACHE_TIME . " seconds\n");
$previous_item_hash = "";
if(file_exists($this->item_hash_file))
$previous_item_hash = file_get_contents($this->item_hash_file);
$this->scan(); // sets $this->max_mtime and $this->item_hash
if( $this->cache_is_stale($cache_date, $this->max_mtime) )
{
self::$DEBUG && print("Cache is stale (cache file mtime: $cache_date, max mtime: {$this->max_mtime}). Uncaching\n");
$this->uncache();
}
elseif( $previous_item_hash != $this->item_hash )
{
self::$DEBUG && print("Cache has changed (before: $previous_item_hash, after: {$this->item_hash}). Uncaching\n");
$this->uncache();
}
else
{
self::$DEBUG && print("Cache is not stale (cache file mtime: $cache_date, max mtime: {$this->max_mtime} and previous hash {$previous_item_hash} and hash {$this->item_hash}). Renewing\n");
$this->renew();
}
}
}
}
/**
* Cache is considered stale (i.e. not a good representation of the source folder) if:
* * the date of the cache is < the date of the most recent modified file OR
* the date of the cache is < the date of the most recent modification to the folder of media
* * AND the most recent change is more than MIN_CACHE_TIME in the past (to avoid incomplete files)
*
* $cache_date is usually the modification time of the cache file itself.
*
* $most_recent_modification is usually passed in from $this->max_mtime i.e. the most recently modified
* podcast media file, and can be 0 if there are no files at all in the podcast yet.
*
* @param int $cache_date
* @param int $most_recent_modification
* @return boolean
*/
protected function cache_is_stale($cache_date, $most_recent_modification)
{
return $cache_date < $most_recent_modification;
}
/**
* Update the date on the cache file so that it's still considered fresh.
*/
public function renew()
{
touch($this->temp_file); // renew cache file life expectancy
}
public function uncache()
{
if($this->isCached())
{
unlink($this->temp_file);
$this->serve_from_cache = false;
}
}
public function generate()
{
if($this->serve_from_cache)
{
if(!file_exists($this->temp_file))
throw new RuntimeException("serve_from_cache set, but cache file not found");
$output = file_get_contents($this->temp_file); // serve cached copy
// extract lastBuiltDate from the cache file. We can't simply use filemtime() of the cache file as
// the mtime is refreshed (to stop us continually rescanning for new content) Also, we don't want to
// parse the whole file, so it's okay to carefully extract it with a regex here, even though that's
// not usually recommended. The regex is chosen to ensure the captured text can't lead to header injection.
preg_match('#([0-9a-zA-Z,:+ ]{1,64})#', $output, $matches);
if(isset($matches[1]))
{
$this->setLastBuildDate($matches[1]);
}
}
else
{
$output = parent::generate();
file_put_contents($this->temp_file, $output); // save cached copy
file_put_contents($this->item_hash_file, $this->item_hash);
$this->serve_from_cache = true;
}
return $output;
}
public function isCached()
{
return file_exists($this->temp_file) && filesize($this->temp_file);
}
}
class Locking_Cached_Dir_Podcast extends Cached_Dir_Podcast
{
protected $file_handle;
public function init()
{
$this->acquireLock();
parent::init();
}
/**
* acquireLock always creates the cache file.
*/
protected function acquireLock()
{
if (!file_exists($this->temp_dir))
{
mkdir($this->temp_dir, 0755, true);
}
$this->file_handle = fopen($this->temp_file, 'a');
if(!flock($this->file_handle, LOCK_EX))
throw new Exception('Locking cache file failed.');
}
/**
* releaseLock will delete the cache file before unlocking if it's empty.
*/
protected function releaseLock()
{
clearstatcache();
if(file_exists($this->temp_file) && !filesize($this->temp_file))
unlink($this->temp_file);
// this releases the lock implicitly
if($this->file_handle)
fclose($this->file_handle);
}
public function __destruct()
{
$this->releaseLock();
}
}
class ErrorHandler
{
public static $primer;
private static $errors = true;
public static function prime($type)
{
self::$primer = $type;
}
public static function defuse()
{
self::$primer = null;
}
public static function errors($state)
{
self::$errors = $state;
}
public static function handle_error($errno, $errstr, $errfile=null, $errline=null, $errcontext=null)
{
// note: this is required to support the @ operator, which getID3 uses extensively
if(error_reporting() & $errno)
{
ErrorHandler::display($errstr, $errfile, $errline);
}
}
/**
* In PHP5, this will be an Exception, but in PHP7 is can be a Throwable
*/
public static function handle_exception( $e )
{
ErrorHandler::display($e->getMessage(), $e->getFile(), $e->getLine());
}
public static function get_primed_error($type)
{
switch($type)
{
case 'ini':
return 'Suggestion: Make sure that your ini file is valid. If the error is on a specific line, try enclosing the value in "quotes".';
case 'getid3':
return 'dir2cast requires getID3. You should download this from ' . DIR2CAST_HOMEPAGE .' and install it with dir2cast.';
}
}
public static function display($message, $errfile, $errline)
{
if(self::$errors)
{
if(!defined('CLI_ONLY'))
{
if(!http_response_code())
{
http_response_code(500);
}
}
if((!defined('CLI_ONLY')) && !ini_get('html_errors'))
{
header("Content-type: text/plain"); // reset the content-type
}
if((!defined('CLI_ONLY')) && ini_get('html_errors'))
{
header("Content-type: text/html"); // reset the content-type
?>
dir2cast error
An error occurred generating your podcast.
This error occurred on line of .
$s_val)
if(!defined($s))
define($s, $s_val);
}
}
}
class SerializationException extends Exception {}
class Dispatcher
{
protected $podcast;
public function __construct(Locking_Cached_Dir_Podcast $podcast)
{
$this->podcast = $podcast;
}
public function uncache_if_forced($force_password, $get)
{
if( strlen($force_password) && isset($get['force']) && $force_password == $get['force'] )
{
$this->podcast->uncache();
}
}
public function uncache_if_output_file()
{
if(defined('OUTPUT_FILE') && !DONT_UNCACHE_IF_OUTPUT_FILE)
{
$this->podcast->uncache();
}
}
public function update_mtime_if_dir2cast_or_settings_modified()
{
// Ensure that the cache is invalidated if we have updated dir2cast.php or dir2cast.ini
// n.b. this doesn't uncache individual media file caches, but also they have a versioning mechanism.
$this->podcast->updateMaxMtime(filemtime(__FILE__), __FILE__);
if(defined('INI_FILE'))
$this->podcast->updateMaxMtime(filemtime(INI_FILE), INI_FILE);
}
public function update_mtime_if_metadata_files_modified()
{
// Ensure that the cache is invalidated if we have updated any of non-episode files used for feed metadata
$metadata_files = array(
'description.txt',
'itunes_summary.txt',
'itunes_subtitle.txt',
'image.jpg',
'image.png',
'itunes_image.jpg',
'itunes_image.png',
);
foreach($metadata_files as $file)
{
$filepath = MP3_DIR() . $file;
if(!file_exists($filepath))
{
$filepath = DIR2CAST_BASE() . $file;
}
if(!file_exists($filepath))
{
continue;
}
$this->podcast->updateMaxMtime(filemtime($filepath), $filepath);
}
}
public function init()
{
$podcast = $this->podcast;
$podcast->init(); // checks the cache file, or scans for media folder if the cache is out of date.
if(!$podcast->isCached())
{
$getid3 = $podcast->addHelper(new Caching_getID3_Podcast_Helper(TMP_DIR, new getID3_Podcast_Helper()));
$atom = $podcast->addHelper(new Atom_Podcast_Helper());
$itunes = $podcast->addHelper(new iTunes_Podcast_Helper());
$podcast->setTitle(TITLE);
$podcast->setLink(LINK);
$podcast->setDescription(DESCRIPTION);
$podcast->setLanguage(LANGUAGE);
$podcast->setCopyright(COPYRIGHT);
$podcast->setWebMaster(WEBMASTER);
$podcast->setTtl(TTL);
$podcast->setImage(IMAGE);
$atom->setSelfLink(RSS_LINK);
$itunes->setSubtitle(ITUNES_SUBTITLE);
$itunes->setAuthor(ITUNES_AUTHOR);
$itunes->setSummary(ITUNES_SUMMARY);
$itunes->setImage(ITUNES_IMAGE);
$itunes->setExplicit(ITUNES_EXPLICIT);
$itunes->setOwnerName(ITUNES_OWNER_NAME);
$itunes->setOwnerEmail(ITUNES_OWNER_EMAIL);
$itunes->addCategories(ITUNES_CATEGORIES);
$podcast->setGenerator(GENERATOR);
}
}
public function output()
{
$podcast = $this->podcast;
if(!defined('OUTPUT_FILE'))
{
$output = $podcast->generate();
if(!defined('CLI_ONLY'))
{
$podcast->http_headers(strlen($output));
}
echo $output;
}
else
{
echo "Writing RSS to: ". OUTPUT_FILE ."\n";
$fh = fopen(OUTPUT_FILE, "w");
fwrite($fh,$podcast->generate());
fclose($fh);
$items = $podcast->getItems();
if(empty($items))
{
echo "** Warning: generated podcast found no episodes.\n";
return -1;
}
}
return 0;
}
}
/* FUNCTIONS **********************************************/
/**
* Strips slashes from a string if magic quotes GPC is enabled; otherwise it's a NO-OP.
*
* @param string $s
* @return string un-mangled $s
*/
function magic_stripslashes($s)
{
if(function_exists('get_magic_quotes_gpc'))
{
return get_magic_quotes_gpc() ? stripslashes($s) : $s;
}
return $s;
}
/**
* Filters a path so that it is not absolute and contains no ".." components.
*
* @param string the path to filter
* @return string filtered path
*
*/
function safe_path($p)
{
return preg_replace('#(?<=^|/)(?:\.\.(?:/|$)|/)#', '', $p);
}
/**
* https://stackoverflow.com/questions/12229572/php-generated-xml-shows-invalid-char-value-27-message
* https://github.com/ben-xo/dir2cast/issues/35
*
* Not all valid UTF-8 characters are valid XML characters. In particular, legacy ASCII control codes
* (such as field separators) are valid UTF-8 but not valid XML. We strip them from the text when
* rendering the XML.
*
*
* @param string text that will go in an XML document
* @return string safer text that will go in an XML
*/
function utf8_for_xml($s)
{
return preg_replace('/[^\x{0009}\x{000a}\x{000d}\x{0020}-\x{D7FF}\x{E000}-\x{FFFD}]+/u', '', $s);
}
function slashdir($dir)
{
return rtrim($dir, '/') . '/';
}
function DIR2CAST_BASE() {
return slashdir(DIR2CAST_BASE);
}
function MP3_BASE() {
return slashdir(MP3_BASE);
}
function MP3_DIR() {
return slashdir(MP3_DIR);
}
/* DISPATCH *********************************************/
function main($args)
{
SettingsHandler::bootstrap(
empty($_SERVER) ? array() : $_SERVER,
empty($_GET) ? array() : $_GET,
empty($args) ? array() : $args
);
SettingsHandler::defaults(
empty($_SERVER) ? array() : $_SERVER
);
$podcast = new Locking_Cached_Dir_Podcast(MP3_DIR(), TMP_DIR);
$podcast->setClockOffset(CLOCK_OFFSET);
$dispatcher = new Dispatcher($podcast);
$dispatcher->uncache_if_forced(FORCE_PASSWORD, $_GET);
$dispatcher->uncache_if_output_file();
if(!defined('IGNORE_DIR2CAST_MTIME'))
$dispatcher->update_mtime_if_dir2cast_or_settings_modified();
$dispatcher->update_mtime_if_metadata_files_modified();
$dispatcher->init();
return $dispatcher->output(); // returns exit code
}
// define NO_DISPATCHER in, say, your test harness
if(!defined('NO_DISPATCHER'))
{
$args = array();
if(isset($GLOBALS['argv'])) {
$args = $argv;
}
try
{
exit(main($args));
}
catch(ExitException $e)
{
print($e->getMessage()."\n");
exit($e->getCode());
}
}
/* THE END *********************************************/