diff --git a/airtime_mvc/application/Bootstrap.php b/airtime_mvc/application/Bootstrap.php index ab250bc27..18ad96c9e 100644 --- a/airtime_mvc/application/Bootstrap.php +++ b/airtime_mvc/application/Bootstrap.php @@ -42,6 +42,10 @@ require_once "MediaType.php"; /* Interfaces */ require_once "OAuth2.php"; require_once "OAuth2Controller.php"; +require_once "Publish.php"; +/* Factories */ +require_once __DIR__.'/services/CeleryServiceFactory.php'; +require_once __DIR__.'/services/PublishServiceFactory.php'; require_once __DIR__.'/forms/helpers/ValidationTypes.php'; require_once __DIR__.'/forms/helpers/CustomDecorators.php'; diff --git a/airtime_mvc/application/common/CeleryManager.php b/airtime_mvc/application/common/CeleryManager.php index 6f8f361d5..6e0c900d0 100644 --- a/airtime_mvc/application/common/CeleryManager.php +++ b/airtime_mvc/application/common/CeleryManager.php @@ -1,7 +1,5 @@ getDbPodcastId()); - // A bit hacky... sort the episodes by publication date to get the most recent - usort($podcastArray["episodes"], array(static::class, "_sortByEpisodePubDate")); - $episodeData = $podcastArray["episodes"][0]; + $episodes = static::_findUningestedEpisodes($podcast, $service); + $podcast->setDbAutoIngestTimestamp(date('r'))->save(); + $service->downloadEpisodes($episodes); + } + + Application_Model_Preference::setPodcastPollLock(microtime(true)); + } + + /** + * Given an ImportedPodcast, find all uningested episodes since the last automatic ingest, + * and add them to a given episodes array + * + * @param ImportedPodcast $podcast the podcast to search + * @param Application_Service_PodcastEpisodeService $service podcast episode service object + * + * @return array array of episodes to append be downloaded + */ + protected static function _findUningestedEpisodes($podcast, $service) { + $podcastArray = Application_Service_PodcastService::getPodcastById($podcast->getDbPodcastId()); + $episodeList = $podcastArray["episodes"]; + $episodes = array(); + // A bit hacky... sort the episodes by publication date to get the most recent + usort($episodeList, array(static::class, "_sortByEpisodePubDate")); + for ($i = 0; $i < sizeof($episodeList); $i++) { + $episodeData = $episodeList[$i]; + // If the publication date of this episode is before the ingest timestamp, we don't need to ingest it + // Since we're sorting by publication date, we can break + if ($episodeData["pub_date"] < $podcast->getDbAutoIngestTimestamp()) break; $episode = PodcastEpisodesQuery::create()->findOneByDbEpisodeGuid($episodeData["guid"]); // Make sure there's no existing episode placeholder or import, and that the data is non-empty if (empty($episode) && !empty($episodeData)) { - $placeholder = $service->addPodcastEpisodePlaceholder($podcast->getDbPodcastId(), $episodeData); + $placeholder = $service->addPlaceholder($podcast->getDbPodcastId(), $episodeData); array_push($episodes, $placeholder); } } - - $service->downloadEpisodes($episodes); - Application_Model_Preference::setPodcastPollLock(microtime(true)); + return $episodes; } /** diff --git a/airtime_mvc/application/common/interface/Publish.php b/airtime_mvc/application/common/interface/Publish.php new file mode 100644 index 000000000..0a99314bf --- /dev/null +++ b/airtime_mvc/application/common/interface/Publish.php @@ -0,0 +1,23 @@ +verifyAPIKey()) { + if ($this->isVerifiedDownload() || $this->verifyAPIKey()) { return true; } @@ -233,7 +233,32 @@ class Zend_Controller_Plugin_Acl extends Zend_Controller_Plugin_Abstract return false; } - + + /** + * Check if the requested file can be downloaded. + * It should satisfy the following requirements: + * * request path is /rest/media/:id/download + * * download key is correct + * * requested file belongs to the station podcast + * + * @return bool + */ + private function isVerifiedDownload() { + $request = $this->getRequest(); + $fileId = $request->getParam("id"); + $key = $request->getParam("download_key"); + $module = $request->getModuleName(); + $controller = $request->getControllerName(); + $action = $request->getActionName(); + $stationPodcast = StationPodcastQuery::create() + ->findOneByDbPodcastId(Application_Model_Preference::getStationPodcastId()); + return $module == "rest" + && $controller == "media" + && $action == "download" + && $key === Application_Model_Preference::getStationPodcastDownloadKey() + && $stationPodcast->hasEpisodeForFile($fileId); + } + private function verifyCSRFToken($token) { return SecurityHelper::verifyCSRFToken($token); } diff --git a/airtime_mvc/application/controllers/plugins/PageLayoutInitPlugin.php b/airtime_mvc/application/controllers/plugins/PageLayoutInitPlugin.php index dca720028..67818ec84 100644 --- a/airtime_mvc/application/controllers/plugins/PageLayoutInitPlugin.php +++ b/airtime_mvc/application/controllers/plugins/PageLayoutInitPlugin.php @@ -157,6 +157,7 @@ class PageLayoutInitPlugin extends Zend_Controller_Plugin_Abstract $view->headScript()->appendFile($baseUrl . 'js/libs/jquery-1.8.3.min.js?' . $CC_CONFIG['airtime_version'], 'text/javascript') ->appendFile($baseUrl . 'js/libs/jquery-ui-1.8.24.min.js?' . $CC_CONFIG['airtime_version'], 'text/javascript') + ->appendFile($baseUrl . 'js/libs/angular.min.js?' . $CC_CONFIG['airtime_version'], 'text/javascript') ->appendFile($baseUrl . 'js/bootstrap/bootstrap.js?' . $CC_CONFIG['airtime_version'], 'text/javascript') ->appendFile($baseUrl . 'js/libs/underscore-min.js?' . $CC_CONFIG['airtime_version'], 'text/javascript') diff --git a/airtime_mvc/application/models/Preference.php b/airtime_mvc/application/models/Preference.php index 65236f2cf..49a8c09c0 100644 --- a/airtime_mvc/application/models/Preference.php +++ b/airtime_mvc/application/models/Preference.php @@ -1525,4 +1525,13 @@ class Application_Model_Preference { self::setValue("station_podcast_id", $value); } + + public static function getStationPodcastDownloadKey() { + return self::getValue("station_podcast_download_key"); + } + + public static function setStationPodcastDownloadKey($value = null) { + $value = empty($value) ? (new Application_Model_Auth())->generateRandomString() : $value; + self::setValue("station_podcast_download_key", $value); + } } diff --git a/airtime_mvc/application/models/airtime/PodcastEpisodes.php b/airtime_mvc/application/models/airtime/PodcastEpisodes.php index 36019ff88..afd48e6b8 100644 --- a/airtime_mvc/application/models/airtime/PodcastEpisodes.php +++ b/airtime_mvc/application/models/airtime/PodcastEpisodes.php @@ -15,4 +15,26 @@ */ class PodcastEpisodes extends BasePodcastEpisodes { + + /** + * @override + * We need to override this function in order to provide the rotating + * download key for the station podcast. + * + * Get the [download_url] column value. + * + * @return string + */ + public function getDbDownloadUrl() { + $podcastId = $this->getDbPodcastId(); + // We may have more station podcasts later, so use this instead of checking the id stored in Preference + $podcast = StationPodcastQuery::create()->findOneByDbPodcastId($podcastId); + if ($podcast) { + $fileId = $this->getDbFileId(); + $key = Application_Model_Preference::getStationPodcastDownloadKey(); + return Application_Common_HTTPHelper::getStationUrl()."rest/media/$fileId/download?download_key=$key"; + } + return parent::getDbDownloadUrl(); + } + } diff --git a/airtime_mvc/application/models/airtime/StationPodcast.php b/airtime_mvc/application/models/airtime/StationPodcast.php index 24075d111..b033bab0c 100644 --- a/airtime_mvc/application/models/airtime/StationPodcast.php +++ b/airtime_mvc/application/models/airtime/StationPodcast.php @@ -15,4 +15,24 @@ */ class StationPodcast extends BaseStationPodcast { + + /** + * Utility function to check whether an episode for the file with the given ID + * is contained within the station podcast + * + * @param int $fileId the file ID to check for + * + * @return bool true if the station podcast contains an episode with + * the given file ID, otherwise false + */ + public function hasEpisodeForFile($fileId) { + $episodes = PodcastEpisodesQuery::create() + ->filterByDbPodcastId($this->getDbPodcastId()) + ->find(); + foreach ($episodes as $e) { + if ($e->getDbFileId() == $fileId) return true; + } + return false; + } + } diff --git a/airtime_mvc/application/models/airtime/map/ImportedPodcastTableMap.php b/airtime_mvc/application/models/airtime/map/ImportedPodcastTableMap.php index 820a05539..53030858e 100644 --- a/airtime_mvc/application/models/airtime/map/ImportedPodcastTableMap.php +++ b/airtime_mvc/application/models/airtime/map/ImportedPodcastTableMap.php @@ -41,6 +41,7 @@ class ImportedPodcastTableMap extends TableMap // columns $this->addPrimaryKey('id', 'DbId', 'INTEGER', true, null, null); $this->addColumn('auto_ingest', 'DbAutoIngest', 'BOOLEAN', true, null, false); + $this->addColumn('auto_ingest_timestamp', 'DbAutoIngestTimestamp', 'TIMESTAMP', false, null, null); $this->addForeignKey('podcast_id', 'DbPodcastId', 'INTEGER', 'podcast', 'id', true, null, null); // validators } // initialize() diff --git a/airtime_mvc/application/models/airtime/om/BaseImportedPodcast.php b/airtime_mvc/application/models/airtime/om/BaseImportedPodcast.php index 21e84891b..1b72d0cef 100644 --- a/airtime_mvc/application/models/airtime/om/BaseImportedPodcast.php +++ b/airtime_mvc/application/models/airtime/om/BaseImportedPodcast.php @@ -42,6 +42,12 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent */ protected $auto_ingest; + /** + * The value for the auto_ingest_timestamp field. + * @var string + */ + protected $auto_ingest_timestamp; + /** * The value for the podcast_id field. * @var int @@ -116,6 +122,41 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent return $this->auto_ingest; } + /** + * Get the [optionally formatted] temporal [auto_ingest_timestamp] column value. + * + * + * @param string $format The date/time format string (either date()-style or strftime()-style). + * If format is null, then the raw DateTime object will be returned. + * @return mixed Formatted date/time value as string or DateTime object (if format is null), null if column is null + * @throws PropelException - if unable to parse/validate the date/time value. + */ + public function getDbAutoIngestTimestamp($format = 'Y-m-d H:i:s') + { + if ($this->auto_ingest_timestamp === null) { + return null; + } + + + try { + $dt = new DateTime($this->auto_ingest_timestamp); + } catch (Exception $x) { + throw new PropelException("Internally stored date/time/timestamp value could not be converted to DateTime: " . var_export($this->auto_ingest_timestamp, true), $x); + } + + if ($format === null) { + // Because propel.useDateTimeClass is true, we return a DateTime object. + return $dt; + } + + if (strpos($format, '%') !== false) { + return strftime($format, $dt->format('U')); + } + + return $dt->format($format); + + } + /** * Get the [podcast_id] column value. * @@ -177,6 +218,29 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent return $this; } // setDbAutoIngest() + /** + * Sets the value of [auto_ingest_timestamp] column to a normalized version of the date/time value specified. + * + * @param mixed $v string, integer (timestamp), or DateTime value. + * Empty strings are treated as null. + * @return ImportedPodcast The current object (for fluent API support) + */ + public function setDbAutoIngestTimestamp($v) + { + $dt = PropelDateTime::newInstance($v, null, 'DateTime'); + if ($this->auto_ingest_timestamp !== null || $dt !== null) { + $currentDateAsString = ($this->auto_ingest_timestamp !== null && $tmpDt = new DateTime($this->auto_ingest_timestamp)) ? $tmpDt->format('Y-m-d H:i:s') : null; + $newDateAsString = $dt ? $dt->format('Y-m-d H:i:s') : null; + if ($currentDateAsString !== $newDateAsString) { + $this->auto_ingest_timestamp = $newDateAsString; + $this->modifiedColumns[] = ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP; + } + } // if either are not null + + + return $this; + } // setDbAutoIngestTimestamp() + /** * Set the value of [podcast_id] column. * @@ -240,7 +304,8 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent $this->id = ($row[$startcol + 0] !== null) ? (int) $row[$startcol + 0] : null; $this->auto_ingest = ($row[$startcol + 1] !== null) ? (boolean) $row[$startcol + 1] : null; - $this->podcast_id = ($row[$startcol + 2] !== null) ? (int) $row[$startcol + 2] : null; + $this->auto_ingest_timestamp = ($row[$startcol + 2] !== null) ? (string) $row[$startcol + 2] : null; + $this->podcast_id = ($row[$startcol + 3] !== null) ? (int) $row[$startcol + 3] : null; $this->resetModified(); $this->setNew(false); @@ -250,7 +315,7 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent } $this->postHydrate($row, $startcol, $rehydrate); - return $startcol + 3; // 3 = ImportedPodcastPeer::NUM_HYDRATE_COLUMNS. + return $startcol + 4; // 4 = ImportedPodcastPeer::NUM_HYDRATE_COLUMNS. } catch (Exception $e) { throw new PropelException("Error populating ImportedPodcast object", $e); @@ -494,6 +559,9 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent if ($this->isColumnModified(ImportedPodcastPeer::AUTO_INGEST)) { $modifiedColumns[':p' . $index++] = '"auto_ingest"'; } + if ($this->isColumnModified(ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP)) { + $modifiedColumns[':p' . $index++] = '"auto_ingest_timestamp"'; + } if ($this->isColumnModified(ImportedPodcastPeer::PODCAST_ID)) { $modifiedColumns[':p' . $index++] = '"podcast_id"'; } @@ -514,6 +582,9 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent case '"auto_ingest"': $stmt->bindValue($identifier, $this->auto_ingest, PDO::PARAM_BOOL); break; + case '"auto_ingest_timestamp"': + $stmt->bindValue($identifier, $this->auto_ingest_timestamp, PDO::PARAM_STR); + break; case '"podcast_id"': $stmt->bindValue($identifier, $this->podcast_id, PDO::PARAM_INT); break; @@ -663,6 +734,9 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent return $this->getDbAutoIngest(); break; case 2: + return $this->getDbAutoIngestTimestamp(); + break; + case 3: return $this->getDbPodcastId(); break; default: @@ -696,7 +770,8 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent $result = array( $keys[0] => $this->getDbId(), $keys[1] => $this->getDbAutoIngest(), - $keys[2] => $this->getDbPodcastId(), + $keys[2] => $this->getDbAutoIngestTimestamp(), + $keys[3] => $this->getDbPodcastId(), ); $virtualColumns = $this->virtualColumns; foreach ($virtualColumns as $key => $virtualColumn) { @@ -748,6 +823,9 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent $this->setDbAutoIngest($value); break; case 2: + $this->setDbAutoIngestTimestamp($value); + break; + case 3: $this->setDbPodcastId($value); break; } // switch() @@ -776,7 +854,8 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent if (array_key_exists($keys[0], $arr)) $this->setDbId($arr[$keys[0]]); if (array_key_exists($keys[1], $arr)) $this->setDbAutoIngest($arr[$keys[1]]); - if (array_key_exists($keys[2], $arr)) $this->setDbPodcastId($arr[$keys[2]]); + if (array_key_exists($keys[2], $arr)) $this->setDbAutoIngestTimestamp($arr[$keys[2]]); + if (array_key_exists($keys[3], $arr)) $this->setDbPodcastId($arr[$keys[3]]); } /** @@ -790,6 +869,7 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent if ($this->isColumnModified(ImportedPodcastPeer::ID)) $criteria->add(ImportedPodcastPeer::ID, $this->id); if ($this->isColumnModified(ImportedPodcastPeer::AUTO_INGEST)) $criteria->add(ImportedPodcastPeer::AUTO_INGEST, $this->auto_ingest); + if ($this->isColumnModified(ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP)) $criteria->add(ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP, $this->auto_ingest_timestamp); if ($this->isColumnModified(ImportedPodcastPeer::PODCAST_ID)) $criteria->add(ImportedPodcastPeer::PODCAST_ID, $this->podcast_id); return $criteria; @@ -855,6 +935,7 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent public function copyInto($copyObj, $deepCopy = false, $makeNew = true) { $copyObj->setDbAutoIngest($this->getDbAutoIngest()); + $copyObj->setDbAutoIngestTimestamp($this->getDbAutoIngestTimestamp()); $copyObj->setDbPodcastId($this->getDbPodcastId()); if ($deepCopy && !$this->startCopy) { @@ -973,6 +1054,7 @@ abstract class BaseImportedPodcast extends BaseObject implements Persistent { $this->id = null; $this->auto_ingest = null; + $this->auto_ingest_timestamp = null; $this->podcast_id = null; $this->alreadyInSave = false; $this->alreadyInValidation = false; diff --git a/airtime_mvc/application/models/airtime/om/BaseImportedPodcastPeer.php b/airtime_mvc/application/models/airtime/om/BaseImportedPodcastPeer.php index 9263b0a67..70aa4080f 100644 --- a/airtime_mvc/application/models/airtime/om/BaseImportedPodcastPeer.php +++ b/airtime_mvc/application/models/airtime/om/BaseImportedPodcastPeer.php @@ -24,13 +24,13 @@ abstract class BaseImportedPodcastPeer const TM_CLASS = 'ImportedPodcastTableMap'; /** The total number of columns. */ - const NUM_COLUMNS = 3; + const NUM_COLUMNS = 4; /** The number of lazy-loaded columns. */ const NUM_LAZY_LOAD_COLUMNS = 0; /** The number of columns to hydrate (NUM_COLUMNS - NUM_LAZY_LOAD_COLUMNS) */ - const NUM_HYDRATE_COLUMNS = 3; + const NUM_HYDRATE_COLUMNS = 4; /** the column name for the id field */ const ID = 'imported_podcast.id'; @@ -38,6 +38,9 @@ abstract class BaseImportedPodcastPeer /** the column name for the auto_ingest field */ const AUTO_INGEST = 'imported_podcast.auto_ingest'; + /** the column name for the auto_ingest_timestamp field */ + const AUTO_INGEST_TIMESTAMP = 'imported_podcast.auto_ingest_timestamp'; + /** the column name for the podcast_id field */ const PODCAST_ID = 'imported_podcast.podcast_id'; @@ -60,12 +63,12 @@ abstract class BaseImportedPodcastPeer * e.g. ImportedPodcastPeer::$fieldNames[ImportedPodcastPeer::TYPE_PHPNAME][0] = 'Id' */ protected static $fieldNames = array ( - BasePeer::TYPE_PHPNAME => array ('DbId', 'DbAutoIngest', 'DbPodcastId', ), - BasePeer::TYPE_STUDLYPHPNAME => array ('dbId', 'dbAutoIngest', 'dbPodcastId', ), - BasePeer::TYPE_COLNAME => array (ImportedPodcastPeer::ID, ImportedPodcastPeer::AUTO_INGEST, ImportedPodcastPeer::PODCAST_ID, ), - BasePeer::TYPE_RAW_COLNAME => array ('ID', 'AUTO_INGEST', 'PODCAST_ID', ), - BasePeer::TYPE_FIELDNAME => array ('id', 'auto_ingest', 'podcast_id', ), - BasePeer::TYPE_NUM => array (0, 1, 2, ) + BasePeer::TYPE_PHPNAME => array ('DbId', 'DbAutoIngest', 'DbAutoIngestTimestamp', 'DbPodcastId', ), + BasePeer::TYPE_STUDLYPHPNAME => array ('dbId', 'dbAutoIngest', 'dbAutoIngestTimestamp', 'dbPodcastId', ), + BasePeer::TYPE_COLNAME => array (ImportedPodcastPeer::ID, ImportedPodcastPeer::AUTO_INGEST, ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP, ImportedPodcastPeer::PODCAST_ID, ), + BasePeer::TYPE_RAW_COLNAME => array ('ID', 'AUTO_INGEST', 'AUTO_INGEST_TIMESTAMP', 'PODCAST_ID', ), + BasePeer::TYPE_FIELDNAME => array ('id', 'auto_ingest', 'auto_ingest_timestamp', 'podcast_id', ), + BasePeer::TYPE_NUM => array (0, 1, 2, 3, ) ); /** @@ -75,12 +78,12 @@ abstract class BaseImportedPodcastPeer * e.g. ImportedPodcastPeer::$fieldNames[BasePeer::TYPE_PHPNAME]['Id'] = 0 */ protected static $fieldKeys = array ( - BasePeer::TYPE_PHPNAME => array ('DbId' => 0, 'DbAutoIngest' => 1, 'DbPodcastId' => 2, ), - BasePeer::TYPE_STUDLYPHPNAME => array ('dbId' => 0, 'dbAutoIngest' => 1, 'dbPodcastId' => 2, ), - BasePeer::TYPE_COLNAME => array (ImportedPodcastPeer::ID => 0, ImportedPodcastPeer::AUTO_INGEST => 1, ImportedPodcastPeer::PODCAST_ID => 2, ), - BasePeer::TYPE_RAW_COLNAME => array ('ID' => 0, 'AUTO_INGEST' => 1, 'PODCAST_ID' => 2, ), - BasePeer::TYPE_FIELDNAME => array ('id' => 0, 'auto_ingest' => 1, 'podcast_id' => 2, ), - BasePeer::TYPE_NUM => array (0, 1, 2, ) + BasePeer::TYPE_PHPNAME => array ('DbId' => 0, 'DbAutoIngest' => 1, 'DbAutoIngestTimestamp' => 2, 'DbPodcastId' => 3, ), + BasePeer::TYPE_STUDLYPHPNAME => array ('dbId' => 0, 'dbAutoIngest' => 1, 'dbAutoIngestTimestamp' => 2, 'dbPodcastId' => 3, ), + BasePeer::TYPE_COLNAME => array (ImportedPodcastPeer::ID => 0, ImportedPodcastPeer::AUTO_INGEST => 1, ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP => 2, ImportedPodcastPeer::PODCAST_ID => 3, ), + BasePeer::TYPE_RAW_COLNAME => array ('ID' => 0, 'AUTO_INGEST' => 1, 'AUTO_INGEST_TIMESTAMP' => 2, 'PODCAST_ID' => 3, ), + BasePeer::TYPE_FIELDNAME => array ('id' => 0, 'auto_ingest' => 1, 'auto_ingest_timestamp' => 2, 'podcast_id' => 3, ), + BasePeer::TYPE_NUM => array (0, 1, 2, 3, ) ); /** @@ -156,10 +159,12 @@ abstract class BaseImportedPodcastPeer if (null === $alias) { $criteria->addSelectColumn(ImportedPodcastPeer::ID); $criteria->addSelectColumn(ImportedPodcastPeer::AUTO_INGEST); + $criteria->addSelectColumn(ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP); $criteria->addSelectColumn(ImportedPodcastPeer::PODCAST_ID); } else { $criteria->addSelectColumn($alias . '.id'); $criteria->addSelectColumn($alias . '.auto_ingest'); + $criteria->addSelectColumn($alias . '.auto_ingest_timestamp'); $criteria->addSelectColumn($alias . '.podcast_id'); } } diff --git a/airtime_mvc/application/models/airtime/om/BaseImportedPodcastQuery.php b/airtime_mvc/application/models/airtime/om/BaseImportedPodcastQuery.php index d859e734f..fa52bc7cc 100644 --- a/airtime_mvc/application/models/airtime/om/BaseImportedPodcastQuery.php +++ b/airtime_mvc/application/models/airtime/om/BaseImportedPodcastQuery.php @@ -8,10 +8,12 @@ * * @method ImportedPodcastQuery orderByDbId($order = Criteria::ASC) Order by the id column * @method ImportedPodcastQuery orderByDbAutoIngest($order = Criteria::ASC) Order by the auto_ingest column + * @method ImportedPodcastQuery orderByDbAutoIngestTimestamp($order = Criteria::ASC) Order by the auto_ingest_timestamp column * @method ImportedPodcastQuery orderByDbPodcastId($order = Criteria::ASC) Order by the podcast_id column * * @method ImportedPodcastQuery groupByDbId() Group by the id column * @method ImportedPodcastQuery groupByDbAutoIngest() Group by the auto_ingest column + * @method ImportedPodcastQuery groupByDbAutoIngestTimestamp() Group by the auto_ingest_timestamp column * @method ImportedPodcastQuery groupByDbPodcastId() Group by the podcast_id column * * @method ImportedPodcastQuery leftJoin($relation) Adds a LEFT JOIN clause to the query @@ -26,10 +28,12 @@ * @method ImportedPodcast findOneOrCreate(PropelPDO $con = null) Return the first ImportedPodcast matching the query, or a new ImportedPodcast object populated from the query conditions when no match is found * * @method ImportedPodcast findOneByDbAutoIngest(boolean $auto_ingest) Return the first ImportedPodcast filtered by the auto_ingest column + * @method ImportedPodcast findOneByDbAutoIngestTimestamp(string $auto_ingest_timestamp) Return the first ImportedPodcast filtered by the auto_ingest_timestamp column * @method ImportedPodcast findOneByDbPodcastId(int $podcast_id) Return the first ImportedPodcast filtered by the podcast_id column * * @method array findByDbId(int $id) Return ImportedPodcast objects filtered by the id column * @method array findByDbAutoIngest(boolean $auto_ingest) Return ImportedPodcast objects filtered by the auto_ingest column + * @method array findByDbAutoIngestTimestamp(string $auto_ingest_timestamp) Return ImportedPodcast objects filtered by the auto_ingest_timestamp column * @method array findByDbPodcastId(int $podcast_id) Return ImportedPodcast objects filtered by the podcast_id column * * @package propel.generator.airtime.om @@ -138,7 +142,7 @@ abstract class BaseImportedPodcastQuery extends ModelCriteria */ protected function findPkSimple($key, $con) { - $sql = 'SELECT "id", "auto_ingest", "podcast_id" FROM "imported_podcast" WHERE "id" = :p0'; + $sql = 'SELECT "id", "auto_ingest", "auto_ingest_timestamp", "podcast_id" FROM "imported_podcast" WHERE "id" = :p0'; try { $stmt = $con->prepare($sql); $stmt->bindValue(':p0', $key, PDO::PARAM_INT); @@ -296,6 +300,49 @@ abstract class BaseImportedPodcastQuery extends ModelCriteria return $this->addUsingAlias(ImportedPodcastPeer::AUTO_INGEST, $dbAutoIngest, $comparison); } + /** + * Filter the query on the auto_ingest_timestamp column + * + * Example usage: + * + * $query->filterByDbAutoIngestTimestamp('2011-03-14'); // WHERE auto_ingest_timestamp = '2011-03-14' + * $query->filterByDbAutoIngestTimestamp('now'); // WHERE auto_ingest_timestamp = '2011-03-14' + * $query->filterByDbAutoIngestTimestamp(array('max' => 'yesterday')); // WHERE auto_ingest_timestamp < '2011-03-13' + * + * + * @param mixed $dbAutoIngestTimestamp The value to use as filter. + * Values can be integers (unix timestamps), DateTime objects, or strings. + * Empty strings are treated as NULL. + * Use scalar values for equality. + * Use array values for in_array() equivalent. + * Use associative array('min' => $minValue, 'max' => $maxValue) for intervals. + * @param string $comparison Operator to use for the column comparison, defaults to Criteria::EQUAL + * + * @return ImportedPodcastQuery The current query, for fluid interface + */ + public function filterByDbAutoIngestTimestamp($dbAutoIngestTimestamp = null, $comparison = null) + { + if (is_array($dbAutoIngestTimestamp)) { + $useMinMax = false; + if (isset($dbAutoIngestTimestamp['min'])) { + $this->addUsingAlias(ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP, $dbAutoIngestTimestamp['min'], Criteria::GREATER_EQUAL); + $useMinMax = true; + } + if (isset($dbAutoIngestTimestamp['max'])) { + $this->addUsingAlias(ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP, $dbAutoIngestTimestamp['max'], Criteria::LESS_EQUAL); + $useMinMax = true; + } + if ($useMinMax) { + return $this; + } + if (null === $comparison) { + $comparison = Criteria::IN; + } + } + + return $this->addUsingAlias(ImportedPodcastPeer::AUTO_INGEST_TIMESTAMP, $dbAutoIngestTimestamp, $comparison); + } + /** * Filter the query on the podcast_id column * diff --git a/airtime_mvc/application/modules/rest/Bootstrap.php b/airtime_mvc/application/modules/rest/Bootstrap.php index 247e18887..08e249970 100644 --- a/airtime_mvc/application/modules/rest/Bootstrap.php +++ b/airtime_mvc/application/modules/rest/Bootstrap.php @@ -71,5 +71,18 @@ class Rest_Bootstrap extends Zend_Application_Module_Bootstrap ) ); $router->addRoute('clear', $clearLibraryRoute); + + $publishRoute = new Zend_Controller_Router_Route( + 'rest/media/:id/publish', + array( + 'controller' => 'media', + 'action' => 'publish', + 'module' => 'rest' + ), + array( + 'id' => '\d+' + ) + ); + $router->addRoute('publish', $publishRoute); } } diff --git a/airtime_mvc/application/modules/rest/controllers/MediaController.php b/airtime_mvc/application/modules/rest/controllers/MediaController.php index d276e829b..424aed435 100644 --- a/airtime_mvc/application/modules/rest/controllers/MediaController.php +++ b/airtime_mvc/application/modules/rest/controllers/MediaController.php @@ -185,6 +185,23 @@ class Rest_MediaController extends Zend_Rest_Controller } } + /** + * Publish endpoint for individual media items + */ + public function publishAction() { + $id = $this->getId(); + try { + // Is there a better way to do this? + $data = json_decode($this->getRequest()->getRawBody(), true)["sources"]; + Application_Service_MediaService::publish($id, $data); + $this->getResponse() + ->setHttpResponseCode(200); + } catch (Exception $e) { + $this->unknownErrorResponse(); + Logging::error($e->getMessage()); + } + } + private function getId() { if (!$id = $this->_getParam('id', false)) { diff --git a/airtime_mvc/application/services/MediaService.php b/airtime_mvc/application/services/MediaService.php index 807d6de17..40464f1a9 100644 --- a/airtime_mvc/application/services/MediaService.php +++ b/airtime_mvc/application/services/MediaService.php @@ -111,9 +111,19 @@ class Application_Service_MediaService } } - - - + /** + * Publish or remove the file with the given file ID from the services + * specified in the request data (ie. SoundCloud, the station podcast) + * + * @param int $fileId ID of the file to be published + * @param array $data request data containing what services to publish to + */ + public static function publish($fileId, $data) { + foreach ($data as $k => $v) { + $service = PublishServiceFactory::getService($k); + $service->$v($fileId); + } + } } diff --git a/airtime_mvc/application/services/PodcastEpisodeService.php b/airtime_mvc/application/services/PodcastEpisodeService.php index 6e8276ed5..db38df1e5 100644 --- a/airtime_mvc/application/services/PodcastEpisodeService.php +++ b/airtime_mvc/application/services/PodcastEpisodeService.php @@ -1,6 +1,6 @@ 'podcast-download' // TODO: rename this to ingest? + self::DOWNLOAD => 'podcast-download' ]; /** @@ -37,7 +37,7 @@ class Application_Service_PodcastEpisodeService extends Application_Service_Thir public function addPodcastEpisodePlaceholders($podcastId, $episodes) { $storedEpisodes = array(); foreach ($episodes as $episode) { - $e = $this->addPodcastEpisodePlaceholder($podcastId, $episode); + $e = $this->addPlaceholder($podcastId, $episode); array_push($storedEpisodes, $e); } return $storedEpisodes; @@ -52,19 +52,33 @@ class Application_Service_PodcastEpisodeService extends Application_Service_Thir * * @return PodcastEpisodes the stored PodcastEpisodes object */ - public function addPodcastEpisodePlaceholder($podcastId, $episode) { + public function addPlaceholder($podcastId, $episode) { // We need to check whether the array is parsed directly from the SimplePie // feed object, or whether it's passed in as json - if ($episode["enclosure"] instanceof SimplePie_Enclosure) { - $url = $episode["enclosure"]->get_link(); - } else { - $url = $episode["enclosure"]["link"]; - } + $enclosure = $episode["enclosure"]; + $url = $enclosure instanceof SimplePie_Enclosure ? $enclosure->get_link() : $enclosure["link"]; + return $this->_buildEpisode($podcastId, $url, $episode["guid"], $episode["pub_date"]); + } + + /** + * Given episode parameters, construct and store a basic PodcastEpisodes object + * + * @param int $podcastId the podcast the episode belongs to + * @param string $url the download URL for the episode + * @param string $guid the unique id for the episode. Often the same as the download URL + * @param string $publicationDate the publication date of the episode + * + * @return PodcastEpisodes the newly created PodcastEpisodes object + * + * @throws Exception + * @throws PropelException + */ + private function _buildEpisode($podcastId, $url, $guid, $publicationDate) { $e = new PodcastEpisodes(); $e->setDbPodcastId($podcastId); $e->setDbDownloadUrl($url); - $e->setDbEpisodeGuid($episode["guid"]); - $e->setDbPublicationDate($episode["pub_date"]); + $e->setDbEpisodeGuid($guid); + $e->setDbPublicationDate($publicationDate); $e->save(); return $e; } @@ -75,25 +89,24 @@ class Application_Service_PodcastEpisodeService extends Application_Service_Thir * @param array $episodes array of podcast episodes */ public function downloadEpisodes($episodes) { - $episodeUrls = array(); /** @var PodcastEpisodes $episode */ foreach($episodes as $episode) { - array_push($episodeUrls, array("id" => $episode->getDbId(), - "url" => $episode->getDbDownloadUrl())); + $this->_download($episode->getDbId(), $episode->getDbDownloadUrl()); } - if (empty($episodeUrls)) return; - $this->_download($episodeUrls); } /** - * Given an array of download URLs, download RSS feed tracks + * Given an episode ID and a download URL, send a Celery task + * to download an RSS feed track * - * @param array $episodes array of episodes containing download URLs and IDs to send to Celery + * @param int $id episode unique ID + * @param string $url download url for the episode */ - private function _download($episodes) { + private function _download($id, $url) { $CC_CONFIG = Config::getConfig(); $data = array( - 'episodes' => $episodes, + 'id' => $id, + 'url' => $url, 'callback_url' => Application_Common_HTTPHelper::getStationUrl() . '/rest/media', 'api_key' => $apiKey = $CC_CONFIG["apiKey"][0], ); @@ -105,7 +118,7 @@ class Application_Service_PodcastEpisodeService extends Application_Service_Thir * * @param $task CeleryTasks the completed CeleryTasks object * @param $episodeId int PodcastEpisodes identifier - * @param $episodes array array containing Podcast episode information + * @param $episode stdClass simple object containing Podcast episode information * @param $status string Celery task status * * @return ThirdPartyTrackReferences the updated ThirdPartyTrackReferences object @@ -113,26 +126,44 @@ class Application_Service_PodcastEpisodeService extends Application_Service_Thir * @throws Exception * @throws PropelException */ - public function updateTrackReference($task, $episodeId, $episodes, $status) { - $ref = parent::updateTrackReference($task, $episodeId, $episodes, $status); + public function updateTrackReference($task, $episodeId, $episode, $status) { + $ref = parent::updateTrackReference($task, $episodeId, $episode, $status); - if ($status == CELERY_SUCCESS_STATUS) { - foreach ($episodes as $episode) { - // Since we process episode downloads as a batch, individual downloads can fail - // even if the task itself succeeds - $dbEpisode = PodcastEpisodesQuery::create() - ->findOneByDbId($episode->episodeid); - if ($episode->status) { - $dbEpisode->setDbFileId($episode->fileid) - ->save(); - } else { - Logging::warn("Celery task $task episode $episode->episodeid unsuccessful with status $episode->status"); - $dbEpisode->delete(); - } - } + $dbEpisode = PodcastEpisodesQuery::create() + ->findOneByDbId($episode->episodeid); + // Even if the task itself succeeds, the download could have failed, so check the status + if ($status == CELERY_SUCCESS_STATUS && $episode->status) { + $dbEpisode->setDbFileId($episode->fileid)->save(); + } else { + Logging::warn("Celery task $task episode $episode->episodeid unsuccessful with status $episode->status"); + $dbEpisode->delete(); } - // TODO: do we need a broader fail condition here? return $ref; } + + /** + * Publish the file with the given file ID to the station podcast + * + * @param int $fileId ID of the file to be published + */ + public function publish($fileId) { + $id = Application_Model_Preference::getStationPodcastId(); + $url = $guid = Application_Common_HTTPHelper::getStationUrl()."rest/media/$fileId/download"; + $e = $this->_buildEpisode($id, $url, $guid, date('r')); + $e->setDbFileId($fileId)->save(); + } + + /** + * Unpublish the file with the given file ID from the station podcast + * + * @param int $fileId ID of the file to be unpublished + */ + public function unpublish($fileId) { + $id = Application_Model_Preference::getStationPodcastId(); + PodcastEpisodesQuery::create() + ->filterByDbPodcastId($id) + ->findOneByDbFileId($fileId) + ->delete(); + } } \ No newline at end of file diff --git a/airtime_mvc/application/services/PodcastService.php b/airtime_mvc/application/services/PodcastService.php index 4e553ed9d..d7f977916 100644 --- a/airtime_mvc/application/services/PodcastService.php +++ b/airtime_mvc/application/services/PodcastService.php @@ -158,7 +158,9 @@ class Application_Service_PodcastService $stationPodcast->save(); Application_Model_Preference::setStationPodcastId($podcast->getDbId()); - + // Set the download key when we create the station podcast + // The value is randomly generated in the setter + Application_Model_Preference::setStationPodcastDownloadKey(); } //TODO move this somewhere where it makes sense diff --git a/airtime_mvc/application/services/PublishServiceFactory.php b/airtime_mvc/application/services/PublishServiceFactory.php new file mode 100644 index 000000000..23ee071f8 --- /dev/null +++ b/airtime_mvc/application/services/PublishServiceFactory.php @@ -0,0 +1,23 @@ + Application_Common_HTTPHelper::getStationUrl() . '/rest/media', + 'callback_url' => Application_Common_HTTPHelper::getStationUrl() . 'rest/media', 'api_key' => $apiKey = $CC_CONFIG["apiKey"][0], 'token' => $this->_accessToken, 'track_id' => $trackId @@ -287,4 +287,25 @@ class Application_Service_SoundcloudService extends Application_Service_ThirdPar } } + /** + * Publish the file with the given file ID to SoundCloud + * + * @param int $fileId ID of the file to be published + */ + public function publish($fileId) { + $this->upload($fileId); + } + + /** + * Unpublish the file with the given file ID from SoundCloud + * + * @param int $fileId ID of the file to be unpublished + * + * @throws ServiceNotFoundException when a $fileId with no corresponding + * service identifier is given + */ + public function unpublish($fileId) { + $this->delete($fileId); + } + } \ No newline at end of file diff --git a/airtime_mvc/application/views/scripts/library/publish-dialog.phtml b/airtime_mvc/application/views/scripts/library/publish-dialog.phtml index 10bd54eb5..5b5ebe314 100644 --- a/airtime_mvc/application/views/scripts/library/publish-dialog.phtml +++ b/airtime_mvc/application/views/scripts/library/publish-dialog.phtml @@ -16,8 +16,10 @@
-
- + +
+ +
diff --git a/airtime_mvc/build/schema.xml b/airtime_mvc/build/schema.xml index e000a698b..bef2aadf7 100644 --- a/airtime_mvc/build/schema.xml +++ b/airtime_mvc/build/schema.xml @@ -598,6 +598,7 @@ + diff --git a/airtime_mvc/build/sql/schema.sql b/airtime_mvc/build/sql/schema.sql index 08fae2e53..a5f456b63 100644 --- a/airtime_mvc/build/sql/schema.sql +++ b/airtime_mvc/build/sql/schema.sql @@ -756,6 +756,7 @@ CREATE TABLE "imported_podcast" ( "id" serial NOT NULL, "auto_ingest" BOOLEAN DEFAULT 'f' NOT NULL, + "auto_ingest_timestamp" TIMESTAMP, "podcast_id" INTEGER NOT NULL, PRIMARY KEY ("id") ); diff --git a/airtime_mvc/public/js/airtime/library/publish.js b/airtime_mvc/public/js/airtime/library/publish.js index 84afba671..7d553f519 100644 --- a/airtime_mvc/public/js/airtime/library/publish.js +++ b/airtime_mvc/public/js/airtime/library/publish.js @@ -17,6 +17,8 @@ var AIRTIME = (function (AIRTIME) { var publishApp = angular.module(PUBLISH_APP_NAME, []) .controller('RestController', function($scope, $http, mediaId, tab) { + $scope.publishSources = {}; + $http.get(endpoint + mediaId, { csrf_token: jQuery("#csrf").val() }) .success(function(json) { console.log(json); @@ -24,8 +26,12 @@ var AIRTIME = (function (AIRTIME) { tab.setName($scope.media.track_title); }); - $scope.save = function() { - $http.put(endpoint + $scope.media.id, { csrf_token: jQuery("#csrf").val(), media: $scope.media }) + $scope.publish = function() { + var sources = {}; + $.each($scope.publishSources, function(k, v) { + if (v) sources[k] = 'publish'; // Tentative TODO: decide on a robust implementation + }); + $http.put(endpoint + $scope.media.id + '/publish', { csrf_token: jQuery("#csrf").val(), sources: sources }) .success(function() { // TODO }); diff --git a/python_apps/airtime-celery/airtime-celery/tasks.py b/python_apps/airtime-celery/airtime-celery/tasks.py index a3904c95f..dcd671808 100644 --- a/python_apps/airtime-celery/airtime-celery/tasks.py +++ b/python_apps/airtime-celery/airtime-celery/tasks.py @@ -86,35 +86,32 @@ def soundcloud_delete(token, track_id): @celery.task(name='podcast-download', acks_late=True) -def podcast_download(episodes, callback_url, api_key): +def podcast_download(id, url, callback_url, api_key): """ Download a batch of podcast episodes - :param episodes: array of episodes containing download URLs and IDs + :param id: episode unique ID + :param url: download url for the episode :param callback_url: callback URL to send the downloaded file to :param api_key: API key for callback authentication :rtype: None """ - response = [] - for episode in episodes: - logger.info(episode) - # Object to store file IDs, episode IDs, and download status - # (important if there's an error before the file is posted) - obj = { 'episodeid': episode['id'] } - try: - re = None - with closing(requests.get(episode['url'], stream=True)) as r: - filename = get_filename(r) - re = requests.post(callback_url, files={'file': (filename, r.content)}, auth=requests.auth.HTTPBasicAuth(api_key, '')) - re.raise_for_status() - f = json.loads(re.content) # Read the response from the media API to get the file id - obj['fileid'] = f['id'] - obj['status'] = 1 - except Exception as e: - logger.info('Error during file download: {0}'.format(e.message)) - obj['status'] = 0 - response.append(obj) - return json.dumps(response) + # Object to store file IDs, episode IDs, and download status + # (important if there's an error before the file is posted) + obj = { 'episodeid': id } + try: + re = None + with closing(requests.get(url, stream=True)) as r: + filename = get_filename(r) + re = requests.post(callback_url, files={'file': (filename, r.content)}, auth=requests.auth.HTTPBasicAuth(api_key, '')) + re.raise_for_status() + f = json.loads(re.content) # Read the response from the media API to get the file id + obj['fileid'] = f['id'] + obj['status'] = 1 + except Exception as e: + logger.info('Error during file download: {0}'.format(e.message)) + obj['status'] = 0 + return json.dumps(obj) def get_filename(r):