Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • adaures/castopod
  • mkljczk/castopod-host
  • spaetz/castopod-host
  • PatrykMis/castopod
  • jonas/castopod
  • ajeremias/castopod
  • misuzu/castopod
  • KrzysztofDomanczyk/castopod
  • Behel/castopod
  • nebulon/castopod
  • ewen/castopod
  • NeoluxConsulting/castopod
  • nateritter/castopod-og
  • prcutler/castopod
14 results
Show changes
Commits on Source (3)
......@@ -128,6 +128,7 @@ nb-configuration.xml
# Visual Studio Code
.vscode/
.history/
tmp/
/results/
......
# [1.13.0](https://code.castopod.org/adaures/castopod/compare/v1.12.11...v1.13.0) (2024-10-25)
### Features
- **api:** add Episode create and publish endpoints
([75cf78e](https://code.castopod.org/adaures/castopod/commit/75cf78e972c52528dc38be050dcb1eb1f8e626fa))
- **rss:** add option for 301 redirect to new feed url
([3a7d26f](https://code.castopod.org/adaures/castopod/commit/3a7d26fdf9bfeffb9247f8efe06d9040ae2fe5ff))
## [1.12.11](https://code.castopod.org/adaures/castopod/compare/v1.12.10...v1.12.11) (2024-10-16)
### Bug Fixes
......
......@@ -11,7 +11,7 @@ declare(strict_types=1);
|
| NOTE: this constant is updated upon release with Continuous Integration.
*/
defined('CP_VERSION') || define('CP_VERSION', '1.12.11');
defined('CP_VERSION') || define('CP_VERSION', '1.13.0');
/*
| --------------------------------------------------------------------
......
......@@ -73,7 +73,11 @@ class Filters extends BaseConfig
'before' => [
// 'honeypot',
'csrf' => [
'except' => ['@[a-zA-Z0-9\_]{1,32}/inbox'],
'except' => [
'@[a-zA-Z0-9\_]{1,32}/inbox',
'api/rest/v1/episodes',
'api/rest/v1/episodes/[0-9]+/publish',
],
],
// 'invalidchars',
],
......
......@@ -33,14 +33,25 @@ class FeedController extends Controller
public function index(string $podcastHandle): ResponseInterface
{
helper(['rss', 'premium_podcasts', 'misc']);
$podcast = (new PodcastModel())->where('handle', $podcastHandle)
->first();
if (! $podcast instanceof Podcast) {
throw PageNotFoundException::forPageNotFound();
}
// 301 redirect to new feed?
$redirectToNewFeed = service('settings')
->get('Podcast.redirect_to_new_feed', 'podcast:' . $podcast->id);
if ($redirectToNewFeed && $podcast->new_feed_url !== null && filter_var(
$podcast->new_feed_url,
FILTER_VALIDATE_URL
) && $podcast->new_feed_url !== current_url()) {
return redirect()->to($podcast->new_feed_url, 301);
}
helper(['rss', 'premium_podcasts', 'misc']);
$service = null;
try {
$service = UserAgentsRSS::find(service('superglobals')->server('HTTP_USER_AGENT'));
......
{
"name": "adaures/castopod",
"version": "1.12.11",
"version": "1.13.0",
"type": "project",
"description": "Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience.",
"homepage": "https://castopod.org",
......
......@@ -367,6 +367,14 @@ class PodcastController extends BaseController
'podcast:' . $this->podcast->id
);
// New feed url redirect
service('settings')
->set(
'Podcast.redirect_to_new_feed',
$this->request->getPost('redirect_to_new_feed') === 'yes',
'podcast:' . $this->podcast->id
);
$db->transComplete();
return redirect()->route('podcast-edit', [$this->podcast->id])->with(
......
......@@ -143,6 +143,8 @@ return [
'verify_txt_helper' => 'This text is injected into a <podcast:txt purpose="verify"> tag.',
'new_feed_url' => 'New feed URL',
'new_feed_url_hint' => 'Use this field when you move to another domain or podcast hosting platform. By default, the value is set to the current RSS URL if the podcast is imported.',
'redirect_to_new_feed' => 'Automatically redirect to new feed (301 redirect)',
'redirect_to_new_feed_hint' => 'Check this when migrating your Castopod RSS feed to the new feed url set above. To ensure followers receive your most recent episodes from the new feed URL, maintain this redirect and the <itunes:new-feed-url> tag in your new feed for at least four weeks.',
'old_feed_url' => 'Old feed URL',
'partnership' => 'Partnership',
'partner_id' => 'ID',
......
......@@ -31,6 +31,8 @@ $routes->group(
],
static function ($routes): void {
$routes->get('/', 'EpisodeController::list');
$routes->post('/', 'EpisodeController::attemptCreate');
$routes->post('(:num)/publish', 'EpisodeController::attemptPublish/$1');
$routes->get('(:num)', 'EpisodeController::view/$1');
$routes->get('(:any)', 'ExceptionController::notFound');
}
......
......@@ -5,11 +5,18 @@ declare(strict_types=1);
namespace Modules\Api\Rest\V1\Controllers;
use App\Entities\Episode;
use App\Entities\Location;
use App\Entities\Podcast;
use App\Entities\Post;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use App\Models\PostModel;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\I18n\Time;
use Modules\Api\Rest\V1\Config\Services;
use Modules\Auth\Models\UserModel;
class EpisodeController extends Controller
{
......@@ -68,6 +75,224 @@ class EpisodeController extends Controller
return $this->respond(static::mapEpisode($episode));
}
public function attemptCreate(): ResponseInterface
{
$rules = [
'created_by' => 'required|is_natural_no_zero',
'updated_by' => 'required|is_natural_no_zero',
'title' => 'required',
'slug' => 'required|max_length[128]',
'podcast_id' => 'required|is_natural_no_zero',
'audio_file' => 'uploaded[audio_file]|ext_in[audio_file,mp3,m4a]',
'cover' => 'permit_empty|is_image[cover]|ext_in[cover,jpg,jpeg,png]|min_dims[cover,1400,1400]|is_image_ratio[cover,1,1]',
'transcript_file' => 'permit_empty|ext_in[transcript_file,srt,vtt]',
'chapters_file' => 'permit_empty|ext_in[chapters_file,json]|is_json[chapters_file]',
'transcript-choice' => 'permit_empty|in_list[upload-file,remote-url]',
'chapters-choice' => 'permit_empty|in_list[upload-file,remote-url]',
];
if (! $this->validate($rules)) {
return $this->failValidationErrors(array_values($this->validator->getErrors()));
}
$podcastId = $this->request->getPost('podcast_id');
$podcast = (new PodcastModel())->getPodcastById($podcastId);
if (! $podcast instanceof Podcast) {
return $this->failNotFound('Podcast not found');
}
$createdByUserId = $this->request->getPost('created_by');
$userModel = new UserModel();
$createdByUser = $userModel->find($createdByUserId);
if (! $createdByUser) {
return $this->failNotFound('User not found');
}
$updatedByUserId = $this->request->getPost('updated_by');
$updatedByUser = $userModel->find($updatedByUserId);
if (! $updatedByUser) {
return $this->failNotFound('Updated by user not found');
}
if ($podcast->type === 'serial' && $this->request->getPost('type') === 'full') {
$rules['episode_number'] = 'required';
}
if (! $this->validate($rules)) {
return $this->failValidationErrors(array_values($this->validator->getErrors()));
}
$validData = $this->validator->getValidated();
if ((new EpisodeModel())
->where([
'slug' => $validData['slug'],
'podcast_id' => $podcast->id,
])
->first() instanceof Episode) {
return $this->fail('An episode with the same slug already exists in this podcast.', 409);
}
$newEpisode = new Episode([
'created_by' => $createdByUserId,
'updated_by' => $updatedByUserId,
'podcast_id' => $podcast->id,
'title' => $validData['title'],
'slug' => $validData['slug'],
'guid' => null,
'audio' => $this->request->getFile('audio_file'),
'cover' => $this->request->getFile('cover'),
'description_markdown' => $this->request->getPost('description'),
'location' => in_array($this->request->getPost('location_name'), ['', null], true)
? null
: new Location($this->request->getPost('location_name')),
'parental_advisory' => $this->request->getPost('parental_advisory') !== 'undefined'
? $this->request->getPost('parental_advisory')
: null,
'number' => $this->request->getPost('episode_number') ? (int) $this->request->getPost(
'episode_number'
) : null,
'season_number' => $this->request->getPost('season_number') ? (int) $this->request->getPost(
'season_number'
) : null,
'type' => $this->request->getPost('type'),
'is_blocked' => $this->request->getPost('block') === 'yes',
'custom_rss_string' => $this->request->getPost('custom_rss'),
'is_premium' => $this->request->getPost('premium') === 'yes',
'published_at' => null,
]);
$transcriptChoice = $this->request->getPost('transcript-choice');
if ($transcriptChoice === 'upload-file') {
$newEpisode->setTranscript($this->request->getFile('transcript_file'));
} elseif ($transcriptChoice === 'remote-url') {
$newEpisode->transcript_remote_url = $this->request->getPost(
'transcript_remote_url'
) === '' ? null : $this->request->getPost('transcript_remote_url');
}
$chaptersChoice = $this->request->getPost('chapters-choice');
if ($chaptersChoice === 'upload-file') {
$newEpisode->setChapters($this->request->getFile('chapters_file'));
} elseif ($chaptersChoice === 'remote-url') {
$newEpisode->chapters_remote_url = $this->request->getPost(
'chapters_remote_url'
) === '' ? null : $this->request->getPost('chapters_remote_url');
}
$episodeModel = new EpisodeModel();
if (! ($newEpisodeId = $episodeModel->insert($newEpisode, true))) {
return $this->fail($episodeModel->errors(), 400);
}
$episode = $episodeModel->find($newEpisodeId)
->toRawArray();
return $this->respond($episode);
}
public function attemptPublish(int $id): ResponseInterface
{
$episodeModel = new EpisodeModel();
$episode = $episodeModel->getEpisodeById($id);
if (! $episode instanceof Episode) {
return $this->failNotFound('Episode not found');
}
if ($episode->publication_status !== 'not_published') {
return $this->fail('Episode is already published or scheduled for publication', 409);
}
$rules = [
'publication_method' => 'required',
'created_by' => 'required|is_natural_no_zero',
];
if (! $this->validate($rules)) {
return $this->failValidationErrors(array_values($this->validator->getErrors()));
}
if ($this->request->getPost('publication_method') === 'schedule') {
$rules['scheduled_publication_date'] = 'required|valid_date[Y-m-d H:i]';
}
if (! $this->validate($rules)) {
return $this->failValidationErrors(array_values($this->validator->getErrors()));
}
$createdByUserId = $this->request->getPost('created_by');
$userModel = new UserModel();
$createdByUser = $userModel->find($createdByUserId);
if (! $createdByUser) {
return $this->failNotFound('User not found');
}
$validData = $this->validator->getValidated();
$db = db_connect();
$db->transStart();
$newPost = new Post([
'actor_id' => $episode->podcast->actor_id,
'episode_id' => $episode->id,
'message' => $this->request->getPost('message') ?? '',
'created_by' => $createdByUserId,
]);
$clientTimezone = $this->request->getPost('client_timezone') ?? app_timezone();
if ($episode->podcast->publication_status === 'published') {
$publishMethod = $validData['publication_method'];
if ($publishMethod === 'schedule') {
$scheduledPublicationDate = $validData['scheduled_publication_date'] ?? null;
if ($scheduledPublicationDate) {
$episode->published_at = Time::createFromFormat(
'Y-m-d H:i',
$scheduledPublicationDate,
$clientTimezone
)->setTimezone(app_timezone());
} else {
$db->transRollback();
return $this->fail('Scheduled publication date is required', 400);
}
} else {
$episode->published_at = Time::now();
}
} elseif ($episode->podcast->publication_status === 'scheduled') {
// podcast publication date has already been set
$episode->published_at = $episode->podcast->published_at->addSeconds(1);
} else {
$episode->published_at = Time::now();
}
$newPost->published_at = $episode->published_at;
$postModel = new PostModel();
if (! $postModel->addPost($newPost)) {
$db->transRollback();
return $this->fail($postModel->errors(), 400);
}
if (! $episodeModel->update($episode->id, $episode)) {
$db->transRollback();
return $this->fail($episodeModel->errors(), 400);
}
$db->transComplete();
// @phpstan-ignore-next-line
return $this->respond(self::mapEpisode($episode));
}
protected static function mapEpisode(Episode $episode): Episode
{
$episode->cover_url = $episode->getCover()
......
......@@ -27,6 +27,13 @@ class ApiFilter implements FilterInterface
throw PageNotFoundException::forPageNotFound();
}
if ($request->getMethod() === 'POST' && ! $restApiConfig->basicAuth) {
/** @var Response $response */
$response = service('response');
$response->setStatusCode(401);
return $response;
}
if ($restApiConfig->basicAuth) {
/** @var Response $response */
$response = service('response');
......
......@@ -72,6 +72,162 @@
}
}
}
},
"/api/rest/v1/episodes": {
"get": {
"summary": "List all episodes",
"responses": {
"200": {
"description": "Object of episodes",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Episodes"
}
}
}
},
"default": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"post": {
"summary": "Create a new episode",
"requestBody": {
"description": "Episode object that needs to be added",
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/EpisodeCreateRequest"
}
}
}
},
"responses": {
"201": {
"description": "Episode created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Episode"
}
}
}
},
"default": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/rest/v1/episodes/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The id of the episode to retrieve",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"maxLength": 10
}
}
],
"get": {
"summary": "Info for a specific episode",
"responses": {
"200": {
"description": "Expected response to a valid request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Episode"
}
}
}
},
"default": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/rest/v1/episodes/{id}/publish": {
"post": {
"summary": "Publish an episode",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The id of the episode to publish",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"maxLength": 10
}
}
],
"requestBody": {
"description": "Publish parameters",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EpisodePublishRequest"
}
}
}
},
"responses": {
"200": {
"description": "Episode published successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Episode"
}
}
}
},
"default": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
},
"components": {
......@@ -302,6 +458,209 @@
"$ref": "#/components/schemas/Podcast"
}
},
"Episode": {
"type": "object",
"required": [
"id",
"title",
"slug",
"podcast_id",
"created_by",
"updated_by",
"created_at",
"updated_at"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"title": {
"type": "string"
},
"slug": {
"type": "string"
},
"podcast_id": {
"type": "integer",
"format": "int64"
},
"description_markdown": {
"type": "string"
},
"description_html": {
"type": "string"
},
"audio_url": {
"type": "string",
"format": "uri"
},
"cover_url": {
"type": "string",
"format": "uri"
},
"duration": {
"type": "integer",
"format": "int32"
},
"published_at": {
"type": "string",
"format": "date-time"
},
"created_by": {
"type": "integer",
"format": "int64"
},
"updated_by": {
"type": "integer",
"format": "int64"
}
}
},
"Episodes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Episode"
}
},
"EpisodeCreateRequest": {
"type": "object",
"required": [
"user_id",
"updated_by",
"title",
"slug",
"podcast_id",
"audio_file"
],
"properties": {
"user_id": {
"type": "integer",
"format": "int64",
"description": "ID of the user creating the episode"
},
"updated_by": {
"type": "integer",
"format": "int64",
"description": "ID of the user updating the episode"
},
"title": {
"type": "string",
"description": "Title of the episode"
},
"slug": {
"type": "string",
"description": "URL-friendly slug of the episode"
},
"podcast_id": {
"type": "integer",
"format": "int64",
"description": "ID of the podcast the episode belongs to"
},
"audio_file": {
"type": "string",
"format": "binary",
"description": "Audio file for the episode"
},
"cover": {
"type": "string",
"format": "binary",
"description": "Cover image for the episode"
},
"description": {
"type": "string",
"description": "Description of the episode"
},
"location_name": {
"type": "string",
"description": "Location associated with the episode"
},
"parental_advisory": {
"type": "string",
"enum": ["clean", "explicit"],
"description": "Parental advisory rating"
},
"episode_number": {
"type": "integer",
"format": "int32",
"description": "Episode number (for serial podcasts)"
},
"season_number": {
"type": "integer",
"format": "int32",
"description": "Season number (for serial podcasts)"
},
"type": {
"type": "string",
"enum": ["full", "trailer", "bonus"],
"description": "Type of episode"
},
"block": {
"type": "string",
"enum": ["yes", "no"],
"description": "Block episode from being published"
},
"custom_rss": {
"type": "string",
"description": "Custom RSS content"
},
"premium": {
"type": "string",
"enum": ["yes", "no"],
"description": "Mark episode as premium content"
},
"transcript-choice": {
"type": "string",
"enum": ["upload-file", "remote-url"],
"description": "Transcript source choice"
},
"transcript_file": {
"type": "string",
"format": "binary",
"description": "Transcript file"
},
"transcript_remote_url": {
"type": "string",
"format": "uri",
"description": "Remote URL for transcript"
},
"chapters-choice": {
"type": "string",
"enum": ["upload-file", "remote-url"],
"description": "Chapters source choice"
},
"chapters_file": {
"type": "string",
"format": "binary",
"description": "Chapters file"
},
"chapters_remote_url": {
"type": "string",
"format": "uri",
"description": "Remote URL for chapters"
}
}
},
"EpisodePublishRequest": {
"type": "object",
"required": ["publication_method"],
"properties": {
"publication_method": {
"type": "string",
"enum": ["now", "schedule", "with_podcast"],
"description": "Method of publication"
},
"scheduled_publication_date": {
"type": "string",
"format": "date-time",
"description": "Scheduled date and time for publication"
},
"client_timezone": {
"type": "string",
"description": "Timezone of the client"
}
}
},
"Error": {
"type": "object",
"properties": {
......
{
"name": "castopod",
"version": "1.12.11",
"version": "1.13.0",
"description": "Castopod Host is an open-source hosting platform made for podcasters who want engage and interact with their audience.",
"private": true,
"license": "AGPL-3.0-or-later",
......
......@@ -262,6 +262,10 @@ label="<?= esc(lang('Podcast.form.new_feed_url')) ?>"
hint="<?= esc(lang('Podcast.form.new_feed_url_hint')) ?>"
value="<?= esc($podcast->new_feed_url) ?>"
/>
<Forms.Toggler name="redirect_to_new_feed" value="yes" checked="<?= service('settings')
->get('Podcast.redirect_to_new_feed', 'podcast:' . $podcast->id) ? 'true' : 'false' ?>" hint="<?= esc(lang('Podcast.form.redirect_to_new_feed_hint')) ?>"><?= lang('Podcast.form.redirect_to_new_feed') ?></Forms.Toggler>
<hr class="border-subtle">
<Forms.Toggler class="mb-2" name="lock" value="yes" checked="<?= $podcast->is_locked ? 'true' : 'false' ?>" hint="<?= esc(lang('Podcast.form.lock_hint')) ?>">
<?= lang('Podcast.form.lock') ?>
......