mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-19 04:51:17 +00:00
feat(api): add Episode create and publish endpoints
This commit is contained in:
parent
8402cc29d2
commit
a90cdfdcdb
1
.gitignore
vendored
1
.gitignore
vendored
@ -128,6 +128,7 @@ nb-configuration.xml
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
.history/
|
||||
tmp/
|
||||
|
||||
/results/
|
||||
|
@ -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',
|
||||
],
|
||||
|
@ -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()
|
||||
|
@ -29,6 +29,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": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user