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 <?php echo VERSION; ?> error

An error occurred generating your podcast.





This error occurred on line of .

Valid HTML 4.01 Strict

$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 *********************************************/