Skip to content
Snippets Groups Projects
Commit 75cf78e9 authored by Nate Ritter's avatar Nate Ritter Committed by Yassine Doghri
Browse files

feat(api): add Episode create and publish endpoints

parent 3a7d26fd
No related branches found
No related tags found
1 merge request!353feat(api): add API Episode create and publish endpoints
Pipeline #18917 passed
Pipeline: Castopod

#18923

    Pipeline: castopod.org

    #18922

      Pipeline: Castopod

      #18921

        ......@@ -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()
        ......
        ......@@ -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": {
        ......
        0% Loading or .
        You are about to add 0 people to the discussion. Proceed with caution.
        Finish editing this message first!
        Please register or to comment