* SAAS-1084 - initial work on publishing API backend

* More work on automatic ingest
* Add automatic_ingest_timestamp column to ImportedPodcast
This commit is contained in:
Duncan Sommerville 2015-10-20 19:03:34 -04:00
parent 3a791ef9b5
commit 0b1df6baf3
27 changed files with 490 additions and 106 deletions

View File

@ -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';

View File

@ -1,7 +1,5 @@
<?php
require_once "CeleryServiceFactory.php";
class CeleryManager {
/**

View File

@ -28,23 +28,43 @@ class PodcastManager {
public static function downloadNewestEpisodes() {
$autoIngestPodcasts = static::_getAutoIngestPodcasts();
$service = new Application_Service_PodcastEpisodeService();
$episodes = array();
foreach ($autoIngestPodcasts as $podcast) {
/** @var ImportedPodcast $podcast */
$podcastArray = Application_Service_PodcastService::getPodcastById($podcast->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;
}
/**

View File

@ -0,0 +1,23 @@
<?php
interface Publish {
/**
* Publish the file with the given file ID
*
* @param int $fileId ID of the file to be published
*
* @return void
*/
public function publish($fileId);
/**
* Unpublish the file with the given file ID
*
* @param int $fileId ID of the file to be unpublished
*
* @return void
*/
public function unpublish($fileId);
}

View File

@ -126,6 +126,9 @@ define('CELERY_FAILED_STATUS', 'FAILED');
define('SOUNDCLOUD_SERVICE_NAME', 'soundcloud');
define('PODCAST_SERVICE_NAME', 'podcast');
// Publish Services
define('STATION_PODCAST_SERVICE_NAME', 'station_podcast');
// Podcast Types
//define('STATION_PODCAST', 0);
//define('IMPORTED_PODCAST', 1);

View File

@ -21,7 +21,7 @@ class SoundcloudController extends ThirdPartyController implements OAuth2Control
}
/**
* Upload the file with the given id to a third-party service
* Upload the file with the given id to SoundCloud
*
* @return void
*
@ -34,7 +34,7 @@ class SoundcloudController extends ThirdPartyController implements OAuth2Control
}
/**
* Download the file with the given id from a third-party service
* Download the file with the given id from SoundCloud
*
* @return void
*
@ -47,7 +47,7 @@ class SoundcloudController extends ThirdPartyController implements OAuth2Control
}
/**
* Delete the file with the given id from a third-party service
* Delete the file with the given id from SoundCloud
*
* @return void
*

View File

@ -223,7 +223,7 @@ class Zend_Controller_Plugin_Acl extends Zend_Controller_Plugin_Abstract
}
private function verifyAuth() {
if ($this->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);
}

View File

@ -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')

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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()

View File

@ -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;

View File

@ -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');
}
}

View File

@ -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:
* <code>
* $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'
* </code>
*
* @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
*

View File

@ -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);
}
}

View File

@ -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)) {

View File

@ -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);
}
}
}

View File

@ -1,6 +1,6 @@
<?php
class Application_Service_PodcastEpisodeService extends Application_Service_ThirdPartyCeleryService
class Application_Service_PodcastEpisodeService extends Application_Service_ThirdPartyCeleryService implements Publish
{
/**
* Arbitrary constant identifiers for the internal tasks array
@ -22,7 +22,7 @@ class Application_Service_PodcastEpisodeService extends Application_Service_Thir
* @var array map of constant identifiers to Celery task names
*/
protected static $_CELERY_TASKS = [
self::DOWNLOAD => '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();
}
}

View File

@ -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

View File

@ -0,0 +1,23 @@
<?php
class PublishServiceFactory {
/**
* Given an identifying string, get a PublishService object of that type
*
* @param $serviceName string the name of the service to create
*
* @return Publish|null
*/
public static function getService($serviceName) {
switch($serviceName) {
case SOUNDCLOUD_SERVICE_NAME:
return new Application_Service_SoundcloudService();
case STATION_PODCAST_SERVICE_NAME:
return new Application_Service_PodcastEpisodeService();
default:
return null;
}
}
}

View File

@ -7,7 +7,7 @@ require_once "ThirdPartyCeleryService.php";
*
* Class Application_Service_SoundcloudService
*/
class Application_Service_SoundcloudService extends Application_Service_ThirdPartyCeleryService implements OAuth2 {
class Application_Service_SoundcloudService extends Application_Service_ThirdPartyCeleryService implements OAuth2, Publish {
/**
* Arbitrary constant identifiers for the internal tasks array
@ -145,7 +145,7 @@ class Application_Service_SoundcloudService extends Application_Service_ThirdPar
public function download($trackId = null) {
$CC_CONFIG = Config::getConfig();
$data = array(
'callback_url' => 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);
}
}

View File

@ -16,8 +16,10 @@
</label>
<fieldset>
<legend><?php echo _("Publish to:"); ?></legend>
<input type="checkbox" name="publish_sources" id="station_podcast" value="station_podcast"><label for="station_podcast"><?php echo(_("My Station Podcast"));?></label><br/>
<input type="checkbox" name="publish_sources" id="soundcloud" value="soundcloud"><label for="soundcloud">SoundCloud</label>
<input ng-model="publishSources.station_podcast" type="checkbox" name="publish_sources" id="station_podcast" value="station_podcast">
<label for="station_podcast"><?php echo(_("My Station Podcast"));?></label><br/>
<input ng-model="publishSources.soundcloud" type="checkbox" name="publish_sources" id="soundcloud" value="soundcloud">
<label for="soundcloud">SoundCloud</label>
</fieldset>
</form>

View File

@ -598,6 +598,7 @@
<table name="imported_podcast" phpName="ImportedPodcast">
<column name="id" phpName="DbId" required="true" primaryKey="true" autoIncrement="true" type="INTEGER"/>
<column name="auto_ingest" phpName="DbAutoIngest" type="BOOLEAN" required="true" defaultValue="false"/>
<column name="auto_ingest_timestamp" phpName="DbAutoIngestTimestamp" type="TIMESTAMP" required="false" />
<column name="podcast_id" phpName="DbPodcastId" required="true" type="INTEGER"/>
<foreign-key foreignTable="podcast" name="podcast_id_fkey" onDelete="CASCADE">
<reference local="podcast_id" foreign="id" />

View File

@ -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")
);

View File

@ -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
});

View File

@ -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):