<?php

/******************************************************************************
 * Copyright (c) 2008-2022, Ben XO (me@ben-xo.com).
 *
 * All rights reserved.
 * 
 * Redistribution and use in source and binary forms, with or without 
 * modification, are permitted provided that the following conditions are met:
 * 
 *  * Redistributions of source code must retain the above copyright notice, 
 *    this list of conditions and the following disclaimer.
 *  * Redistributions in binary form must reproduce the above copyright notice, 
 *    this list of conditions and the following disclaimer in the documentation 
 *    and/or other materials provided with the distribution.
 *  * Neither the name of dir2cast nor the names of its contributors may be used 
 *    to endorse or promote products derived from this software without specific 
 *    prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
 ******************************************************************************
 *
 * USAGE:
 * 
 * 1) edit dir2cast.ini and fill in the settings.
 * 
 * 2) visit:
 * 
 * http://www.yoursite.com/dir2cast.php
 * 
 * or
 * 
 * http://www.yoursite.com/dir2cast.php?dir=my_mp3_subdir 
 * ^-- check in the README.txt for a way to get pretty URLS using mod_rewrite
 * 
 * or
 * 
 * user$ php ./dir2cast.php my_mp3_dir 
 * ^-- from the command line
 * 
 * If your MP3 dir is different from the dir the script is in, then you can have
 * have a master dir2cast.ini in the same dir as dir2cast.php, and cast-specific
 * configuration in another dir2cast.ini in the same dir as your MP3s. 
 */

/* DEFAULTS *********************************************/

// error handler needs these, so let's set them now.
define('VERSION', '1.38');
define('DIR2CAST_HOMEPAGE', 'https://github.com/ben-xo/dir2cast/');
define('GENERATOR', 'dir2cast ' . VERSION . ' by Ben XO (' . DIR2CAST_HOMEPAGE . ')');

error_reporting(E_ALL & ~E_DEPRECATED);
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
set_error_handler( array('ErrorHandler', 'handle_error') );
set_exception_handler( array( 'ErrorHandler', 'handle_exception') );

// Best do everything in UTC.
date_default_timezone_set( 'UTC' );

/* EXTERNALS ********************************************/

function __autoloader($class_name)
{
    switch(strtolower($class_name))
    {
        case 'getid3':
            
            ErrorHandler::prime('getid3');
            if(file_exists('getID3/getid3.php'))
                require_once('getID3/getid3.php');
            else
                require_once('getid3/getid3.php');
            ErrorHandler::defuse();
            break;
            
        default:
            if(file_exists($class_name . '.php'))
                require_once $class_name . '.php';
    }
}
spl_autoload_register('__autoloader');

/* CLASSES **********************************************/

class ExitException extends Exception {}

abstract class GetterSetter {
    
    protected $parameters = array();
    
    /**
     * Missing Method Magic Accessor
     *
     * @param string $method Method to call (get* or set*)
     * @param array $params array of parameters for the method 
     * @return mixed the result of the method
     */
    public function __call($method, $params)
    {
        $var_name = substr($method, 3);
        $var_name[0] = strtolower($var_name[0]);
        switch(strtolower(substr($method, 0, 3)))
        {
            case 'get':
                if(isset($this->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)
    {
        /*
         *    <itunes:author>John Doe</itunes:author>
         *    <itunes:duration>7:04</itunes:duration>
         *    <itunes:subtitle>A short primer on table spices</itunes:subtitle>
         *    <itunes:summary>This week we talk about salt and pepper shakers, 
         *                  [...] Come and join the party!</itunes:summary>
         *    <itunes:keywords>salt, pepper, shaker, exciting</itunes:keywords>
         */

        $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 <description> 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 <br />. 
                    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 .<whatever> 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 .<whatever> 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 .<whatever> 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('&amp;', '&lt;', '&gt;'), 
            array('&#x26;', '&#x3C;', '&#x3E;'), 
            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('#<lastBuildDate>([0-9a-zA-Z,:+ ]{1,64})</lastBuildDate>#', $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 <a href="' . DIR2CAST_HOMEPAGE . '">' . DIR2CAST_HOMEPAGE .'</a> 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
                        
                ?><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
                <html><head><title>dir2cast <?php echo VERSION; ?> error</title>
                <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> 
                <style type="text/css">
                    body { font-family: Calibri, Arial, Helvetica, sans-serif; font-size: 16px; }
                    h1 { font-weight: bold; font-size: 200%; }
                    a img { border: 0px; }
                    #footer { font-size: 12px; margin-top: 1em;}
                    #the_error { border: 1px red solid; padding: 1em; } 
                    #additional_error { font-size: 14px; }
                </style>
                </head>
                <body>
                    <h1>An error occurred generating your podcast.</h1>
                    <div id="the_error">
                        <?php echo $message; ?>
                        <?php if(!empty(ErrorHandler::$primer)): ?>
                            <br><br>
                            <?php echo self::get_primed_error(ErrorHandler::$primer); ?>
                        <?php endif; ?>
                        <?php if(http_response_code() == 500): ?>
                            <br><br>
                            <div id="additional_error">
                                This error occurred on line <?php echo $errline; ?> of <?php echo $errfile; ?>.
                            </div>
                        <?php endif; ?>
                    </div>
                    <div id="footer"><a href="<?php echo DIR2CAST_HOMEPAGE ?>">dir2cast</a> <?php echo VERSION; ?> by Ben XO</div>
                    <p>
                        <a href="http://validator.w3.org/check?uri=referer"><img
                            src="http://www.w3.org/Icons/valid-html401"
                            alt="Valid HTML 4.01 Strict" height="31" width="88"></a>
                    </p>
                </body></html>
                <?php
            }
            else
            {
                // This case happens when define('CLI_ONLY') || !ini_get('html_errors')
                echo "Error: $message (on line $errline of $errfile)\n";
                if(!empty(ErrorHandler::$primer))
                    echo strip_tags(self::get_primed_error(ErrorHandler::$primer)) . "\n";
            }

            exit(-1); // can't throw - this is the exception handler
        }
    }

    public static function display404($message)
    {
        if(defined('CLI_ONLY'))
        {
            http_response_code(404);
            header("Content-type: text/plain");
        }
        throw new ExitException("Not Found: $message", -2);
    }
}

class SettingsHandler
{
    private static $settings_cache = array();
    
    /**
     * getopt() uses argv directly and is a pain to mock. It's nicer to pass argv in,
     * but mocking it a pain.
     */
    public static function getopt($argv_in, $short_options, $long_options)
    {
        if(isset($GLOBALS['argv']) && $argv_in != $GLOBALS['argv'])
        {
            return fake_getopt($argv_in, $short_options, $long_options);
        }
        return getopt($short_options, $long_options);
    }



    /**
     * This method sets up all app-wide settings that are required at initialization time.
     * 
     * @param $SERVER Array HTTP Server array containing HTTP_HOST, SCRIPT_FILENAME, DOCUMENT_ROOT, HTTPS
     * @param $GET Array HTTP GET
     * @param $argv Array command line options
     */
    public static function bootstrap(array $SERVER, array $GET, array $argv)
    {

        if(!defined('CLI_ONLY') && isset($argv) && isset($argv[0])) {
            define('CLI_ONLY', true);
        }

        // do not use DIR2CAST_BASE directly. use DIR2CAST_BASE()
        if(defined('CLI_ONLY')) {
            define('DIR2CAST_BASE', realpath(dirname($argv[0])));
        } else {
            define('DIR2CAST_BASE', dirname(__FILE__));
        }

        // If an installation-wide config file exists, load it now.
        // Installation-wide config can contain TMP_DIR, MP3_DIR and MIN_CACHE_TIME.
        // Anything else it contains will be used as a fall-back if no dir-specific dir2cast.ini exists
        if(file_exists( DIR2CAST_BASE() . 'dir2cast.ini' ))
        {
            $ini_file_name = DIR2CAST_BASE() . 'dir2cast.ini';
            self::load_from_ini( $ini_file_name );
            self::finalize(array('TMP_DIR', 'MP3_BASE', 'MP3_DIR', 'MIN_CACHE_TIME', 'FORCE_PASSWORD'));
            define('INI_FILE', $ini_file_name);
        }
        
        $cli_options = self::getopt($argv, '', array('help', 'media-dir::', 'media-url::', 'output::', 'dont-uncache', 'min-file-age::', 'debug', 'ignore-dir2cast-mtime', 'clock-offset::'));
        if($cli_options) {
            if(isset($cli_options['help'])) {
                print "Usage: php dir2cast.php [--help] [--media-dir=MP3_DIR] [--media-url=MP3_URL] [--output=OUTPUT_FILE]\n";

                // extra debugging / test harness options:
                // [--dont-uncache]
                // [--min-file-age=MIN_FILE_AGE]
                // [--debug]
                // [--ignore-dir2cast-mtime]
                // [--clock-offset=CLOCK_OFFSET]

                exit;
            }
            if(!defined('MP3_DIR') && !empty($cli_options['media-dir']))
            {

                if(!is_dir($cli_options['media-dir']) or !is_readable($cli_options['media-dir']))
                {
                    ErrorHandler::display404($cli_options['media-dir']);
                }
                // do not use MP3_DIR directly. use MP3_DIR()
                define('MP3_DIR', slashdir(realpath($cli_options['media-dir'])));
            }
            if(!defined('MP3_URL') && !empty($cli_options['media-url']))
            {
                define('MP3_URL', $cli_options['media-url']);
            }
            if(!defined('OUTPUT_FILE') && !empty($cli_options['output']))
            {
                define('OUTPUT_FILE', $cli_options['output']);
            }
            if(!defined('DONT_UNCACHE_IF_OUTPUT_FILE') && isset($cli_options['dont-uncache']))
            {
                define('DONT_UNCACHE_IF_OUTPUT_FILE', true);
            }
            if(!defined('MIN_FILE_AGE') && isset($cli_options['min-file-age']))
            {
                define('MIN_FILE_AGE', (int)$cli_options['min-file-age']);
            }
            if(!defined('DEBUG') && isset($cli_options['debug']))
            {
                define('DEBUG', true);
            }
            if(!defined('IGNORE_DIR2CAST_MTIME') && isset($cli_options['ignore-dir2cast-mtime']))
            {
                define('IGNORE_DIR2CAST_MTIME', true);
            }
            if(!defined('CLOCK_OFFSET') && isset($cli_options['clock-offset']))
            {
                define('CLOCK_OFFSET', (int)$cli_options['clock-offset']);
            }
        }
       
        if(!defined('MIN_CACHE_TIME'))
            define('MIN_CACHE_TIME', 5);
        
        if(!defined('FORCE_PASSWORD'))
            define('FORCE_PASSWORD', '');

        if(!defined('TMP_DIR')) {
            define('TMP_DIR', DIR2CAST_BASE() . 'temp');
        }

        // do not use MP3_BASE directly. use MP3_BASE()
        if(!defined('MP3_BASE'))
        {
            if(!empty($SERVER['HTTP_HOST']))
                define('MP3_BASE', dirname($SERVER['SCRIPT_FILENAME']));
            else
                define('MP3_BASE', DIR2CAST_BASE());
        }

        // do not use MP3_DIR directly. use MP3_DIR()
        if(!defined('MP3_DIR'))
        {
            if(!empty($GET['dir']))
            {
                define('MP3_DIR', MP3_BASE() . safe_path(magic_stripslashes($GET['dir'])));
                if(!is_dir(MP3_DIR()) or !is_readable(MP3_DIR()))
                {
                    ErrorHandler::display404($GET['dir']);
                }
            }
            else
                define('MP3_DIR', MP3_BASE());
        }
    }
    
    /**
     * This method sets up all fall-back default instance settings AFTER all .ini files have been loaded.
     */
    public static function defaults(array $SERVER)
    {
        // if an MP3_DIR specific config file exists, load it now, as long as it's not the same file as the global one!
        if( 
            file_exists( MP3_DIR() . 'dir2cast.ini' ) and    
            realpath(DIR2CAST_BASE() . 'dir2cast.ini') != realpath( MP3_DIR() . 'dir2cast.ini' ) 
        ) {
            self::load_from_ini( MP3_DIR() . 'dir2cast.ini' );
        }
        
        self::finalize();
        
        if(!defined('MP3_URL'))
        {
            # This works on the principle that MP3_DIR must be under DOCUMENT_ROOT (otherwise how will you serve the MP3s?)
            # This may fail if MP3_DIR, or one of its parents under DOCUMENT_ROOT, is a symlink. In that case you will have
            # to set this manually.
            
            if(!empty($SERVER['HTTP_HOST']))
            {
                $path_part = substr(MP3_DIR(), strlen(slashdir($SERVER['DOCUMENT_ROOT'])));
                define('MP3_URL', 
                    'http' . (!empty($SERVER['HTTPS']) ? 's' : '') . '://' . $SERVER['HTTP_HOST'] . '/' . ltrim( slashdir( $path_part ), '/' ));
            }
            else
                define('MP3_URL', 'file://' . MP3_DIR() );
        }

        if(!defined('TITLE'))
        {
            if(basename(MP3_DIR()))
                define('TITLE', basename(MP3_DIR()));
            else
                define('TITLE', 'My First dir2cast Podcast');
        }
        
        if(!defined('LINK'))
        {
            if(!empty($SERVER['HTTP_HOST']))
                define('LINK', 'http' . (empty($SERVER['HTTPS']) ? '' : 's') . '://' . $SERVER['HTTP_HOST'] . $SERVER['PHP_SELF']);
            else
                define('LINK', 'http://www.example.com/');
        }

        if(!defined('RSS_LINK'))
        {
            if(!empty($SERVER['HTTP_HOST']))
                define('RSS_LINK', 'http' . (empty($SERVER['HTTPS']) ? '' : 's') . '://' . $SERVER['HTTP_HOST'] . $SERVER['PHP_SELF']);
            else
                define('RSS_LINK', 'http://www.example.com/rss');
        }
        
        if(!defined('DESCRIPTION'))
        {
            if(file_exists(MP3_DIR() . 'description.txt'))
                define('DESCRIPTION', file_get_contents(MP3_DIR() . 'description.txt'));
            elseif(file_exists(DIR2CAST_BASE() . 'description.txt'))
                define('DESCRIPTION', file_get_contents(DIR2CAST_BASE() . 'description.txt'));
            else
                define('DESCRIPTION', 'Podcast');
        }
        
        if(!defined('ATOM_TYPE'))
            define('ATOM_TYPE','application/rss+xml');

        if(!defined('LANGUAGE'))
            define('LANGUAGE', 'en-us');
        
        if(!defined('COPYRIGHT'))
            define('COPYRIGHT', date('Y'));
            
        if(!defined('TTL'))
            define('TTL', 60);
            
        if(!defined('ITEM_COUNT'))
            define('ITEM_COUNT', 10);
            
        if(!defined('ITUNES_SUBTITLE'))
        {
            if(file_exists(MP3_DIR() . 'itunes_subtitle.txt'))
                define('ITUNES_SUBTITLE', file_get_contents(MP3_DIR() . 'itunes_subtitle.txt'));
            elseif(file_exists(DIR2CAST_BASE() . 'itunes_subtitle.txt'))
                define('ITUNES_SUBTITLE', file_get_contents(DIR2CAST_BASE() . 'itunes_subtitle.txt'));
            else
                define('ITUNES_SUBTITLE', DESCRIPTION);
        }
        
        if(!defined('ITUNES_SUMMARY'))
        {
            if(file_exists(MP3_DIR() . 'itunes_summary.txt'))
                define('ITUNES_SUMMARY', file_get_contents(MP3_DIR() . 'itunes_summary.txt'));
            elseif(file_exists(DIR2CAST_BASE() . 'itunes_summary.txt'))
                define('ITUNES_SUMMARY', file_get_contents(DIR2CAST_BASE() . 'itunes_summary.txt'));
            else
                define('ITUNES_SUMMARY', DESCRIPTION);
        }

        if(!defined('IMAGE'))
        {
            if(file_exists(MP3_DIR() . 'image.jpg'))
                define('IMAGE', rtrim(MP3_URL, '/') . '/image.jpg');
            elseif(file_exists(MP3_DIR() . 'image.png'))
                define('IMAGE', rtrim(MP3_URL, '/') . '/image.png');
            elseif(file_exists(DIR2CAST_BASE() . 'image.jpg'))
                define('IMAGE', rtrim(MP3_URL, '/') . '/image.jpg');
            elseif(file_exists(DIR2CAST_BASE() . 'image.png'))
                define('IMAGE', rtrim(MP3_URL, '/') . '/image.png');
            else
                define('IMAGE', '');
        }
        
        if(!defined('ITUNES_IMAGE'))
        {
            if(file_exists(MP3_DIR() . 'itunes_image.jpg'))
                define('ITUNES_IMAGE', rtrim(MP3_URL, '/') . '/itunes_image.jpg');
            elseif(file_exists(MP3_DIR() . 'itunes_image.png'))
                define('ITUNES_IMAGE', rtrim(MP3_URL, '/') . '/itunes_image.png');
            elseif(file_exists(DIR2CAST_BASE() . 'itunes_image.jpg'))
                define('ITUNES_IMAGE', rtrim(MP3_URL, '/') . '/itunes_image.jpg');
            elseif(file_exists(DIR2CAST_BASE() . 'itunes_image.png'))
                define('ITUNES_IMAGE', rtrim(MP3_URL, '/') . '/itunes_image.png');
            else
                define('ITUNES_IMAGE', '');
        }
        
        if(!defined('ITUNES_OWNER_NAME'))
            define('ITUNES_OWNER_NAME', '');
        
        if(!defined('ITUNES_OWNER_EMAIL'))
            define('ITUNES_OWNER_EMAIL', '');
        
        if(!defined('WEBMASTER'))
        {
            if(ITUNES_OWNER_NAME != '' and ITUNES_OWNER_EMAIL != '')
                define('WEBMASTER', ITUNES_OWNER_EMAIL . ' (' . ITUNES_OWNER_NAME . ')');
            else
                define('WEBMASTER', '');
        }
        
        if(!defined('ITUNES_AUTHOR'))
            define('ITUNES_AUTHOR', WEBMASTER);
        
        if(!defined('ITUNES_CATEGORIES'))
            define('ITUNES_CATEGORIES', '');
        
        if(!defined('ITUNES_EXPLICIT'))
            define('ITUNES_EXPLICIT', '');
            
        if(!defined('LONG_TITLES'))
            define('LONG_TITLES', false);

        if(!defined('ITUNES_SUBTITLE_SUFFIX'))
            define('ITUNES_SUBTITLE_SUFFIX', '');

        if(!defined('ITUNES_TYPE'))
            define('ITUNES_TYPE', "episodic");

        if(!defined('DESCRIPTION_SOURCE'))
            define('DESCRIPTION_SOURCE', 'comment');

        if(!defined('RECURSIVE_DIRECTORY_ITERATOR'))
            define('RECURSIVE_DIRECTORY_ITERATOR', false);

        if(!defined('AUTO_SAVE_COVER_ART'))
            define('AUTO_SAVE_COVER_ART', true);

        if(!defined('DONT_UNCACHE_IF_OUTPUT_FILE'))
            define('DONT_UNCACHE_IF_OUTPUT_FILE', false);

        if(!defined('MIN_FILE_AGE'))
            define('MIN_FILE_AGE', 30);

        if(!defined('DEBUG'))
            define('DEBUG', false);

        if(!defined('CLOCK_OFFSET'))
            define('CLOCK_OFFSET', 0);

        // Set up factory settings for Podcast subclasses
        Dir_Podcast::$EMPTY_PODCAST_IS_ERROR = !defined('CLI_ONLY');
        Dir_Podcast::$RECURSIVE_DIRECTORY_ITERATOR = RECURSIVE_DIRECTORY_ITERATOR;
        Dir_Podcast::$ITEM_COUNT = ITEM_COUNT;
        Dir_Podcast::$MIN_FILE_AGE = MIN_FILE_AGE;
        Dir_Podcast::$DEBUG = DEBUG;
        Cached_Dir_Podcast::$MIN_CACHE_TIME = MIN_CACHE_TIME;
        getID3_Podcast_Helper::$AUTO_SAVE_COVER_ART = AUTO_SAVE_COVER_ART;
        iTunes_Podcast_Helper::$ITUNES_SUBTITLE_SUFFIX = ITUNES_SUBTITLE_SUFFIX;
        iTunes_Podcast_Helper::$ITUNES_TYPE = ITUNES_TYPE;

        // Set up up factory settings for RSS Items
        RSS_File_Item::$FILES_URL = MP3_URL; // TODO: rename this to MEDIA_URL
        RSS_File_Item::$FILES_DIR = MP3_DIR(); // TODO: rename this to MEDIA_DIR
        Media_RSS_Item::$LONG_TITLES = LONG_TITLES;
        Media_RSS_Item::$DESCRIPTION_SOURCE = DESCRIPTION_SOURCE;
    }
    
    public static function load_from_ini($file)
    {
        ErrorHandler::prime('ini');
        $settings = parse_ini_file($file); 
        ErrorHandler::defuse();
        
        self::$settings_cache = array_merge(self::$settings_cache, $settings);
    }
    
    public static function finalize($setting_names=null)
    {
        if(is_array($setting_names))
            // define only those listed
            foreach($setting_names as $s)
                !defined($s) and 
                    isset(self::$settings_cache[$s]) and
                        define($s, self::$settings_cache[$s]);
        else
        {
            // define all
            foreach(self::$settings_cache as $s => $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 *********************************************/
