diff --git a/app/Config/Mimes.php b/app/Config/Mimes.php index ac5c7f4638c24cd16d068def03d591d0bbb1e482..da0cd335eece0b7b2198a0b7cf78a6caab8f90a2 100644 --- a/app/Config/Mimes.php +++ b/app/Config/Mimes.php @@ -307,7 +307,7 @@ class Mimes ], 'svg' => ['image/svg+xml', 'application/xml', 'text/xml'], 'vcf' => 'text/x-vcard', - 'srt' => ['text/srt', 'text/plain'], + 'srt' => ['text/srt', 'text/plain', 'application/octet-stream'], 'vtt' => ['text/vtt', 'text/plain'], 'ico' => ['image/x-icon', 'image/x-ico', 'image/vnd.microsoft.icon'], ]; diff --git a/app/Config/Routes.php b/app/Config/Routes.php index ada6c2c10e4d4803cf40da28f2a21d28a461cdd2..23c5e95152c5ce5e87ace05e444b0df2fc0d4cd4 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -237,6 +237,22 @@ $routes->group( 'as' => 'episode-delete', 'filter' => 'permission:podcast_episodes-delete', ]); + $routes->get( + 'transcript-delete', + 'Episode::transcriptDelete/$1/$2', + [ + 'as' => 'transcript-delete', + 'filter' => 'permission:podcast_episodes-edit', + ] + ); + $routes->get( + 'chapters-delete', + 'Episode::chaptersDelete/$1/$2', + [ + 'as' => 'chapters-delete', + 'filter' => 'permission:podcast_episodes-edit', + ] + ); }); }); diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php index 0e1d348f61ee19eeac0ffd31e59df2ea408a0b5a..4f2531c8cdb02fb00602fd86e4502ffc53869449 100644 --- a/app/Controllers/Admin/Episode.php +++ b/app/Controllers/Admin/Episode.php @@ -96,6 +96,8 @@ class Episode extends BaseController 'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]', 'image' => 'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]', + 'transcript' => 'ext_in[transcript,txt,html,srt,json]', + 'chapters' => 'ext_in[chapters,json]', 'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty', ]; @@ -114,6 +116,8 @@ class Episode extends BaseController 'enclosure' => $this->request->getFile('enclosure'), 'description_markdown' => $this->request->getPost('description'), 'image' => $this->request->getFile('image'), + 'transcript' => $this->request->getFile('transcript'), + 'chapters' => $this->request->getFile('chapters'), 'parental_advisory' => $this->request->getPost('parental_advisory') !== 'undefined' ? $this->request->getPost('parental_advisory') @@ -189,6 +193,8 @@ class Episode extends BaseController 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty', 'image' => 'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]', + 'transcript' => 'ext_in[transcript,txt,html,srt,json]', + 'chapters' => 'ext_in[chapters,json]', 'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty', ]; @@ -231,6 +237,14 @@ class Episode extends BaseController if ($image) { $this->episode->image = $image; } + $transcript = $this->request->getFile('transcript'); + if ($transcript->isValid()) { + $this->episode->transcript = $transcript; + } + $chapters = $this->request->getFile('chapters'); + if ($chapters->isValid()) { + $this->episode->chapters = $chapters; + } $episodeModel = new EpisodeModel(); @@ -262,6 +276,40 @@ class Episode extends BaseController ]); } + public function transcriptDelete() + { + unlink($this->episode->transcript); + $this->episode->transcript_uri = null; + + $episodeModel = new EpisodeModel(); + + if (!$episodeModel->update($this->episode->id, $this->episode)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $episodeModel->errors()); + } + + return redirect()->back(); + } + + public function chaptersDelete() + { + unlink($this->episode->chapters); + $this->episode->chapters_uri = null; + + $episodeModel = new EpisodeModel(); + + if (!$episodeModel->update($this->episode->id, $this->episode)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $episodeModel->errors()); + } + + return redirect()->back(); + } + public function delete() { (new EpisodeModel())->delete($this->episode->id); diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php index 8449dbc70fc1e81148d902700ba0a3a9d0033281..8147df8fb11a583214072096dac4703031de3fa7 100644 --- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php +++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php @@ -73,6 +73,16 @@ class AddEpisodes extends Migration 'constraint' => 255, 'null' => true, ], + 'transcript_uri' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'chapters_uri' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], 'parental_advisory' => [ 'type' => 'ENUM', 'constraint' => ['clean', 'explicit'], diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 6b4939a5a044b461bd6402558109974b7df102b5..b316e97e292d75f09fbe075e7cb1e3ea96c1a0a6 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -35,6 +35,16 @@ class Episode extends Entity */ protected $enclosure; + /** + * @var \CodeIgniter\Files\File + */ + protected $transcript; + + /** + * @var \CodeIgniter\Files\File + */ + protected $chapters; + /** * @var string */ @@ -55,6 +65,16 @@ class Episode extends Entity */ protected $enclosure_opengraph_url; + /** + * @var string + */ + protected $transcript_url; + + /** + * @var string + */ + protected $chapters_url; + /** * Holds text only description, striped of any markdown or html special characters * @@ -86,6 +106,8 @@ class Episode extends Entity 'description_markdown' => 'string', 'description_html' => 'string', 'image_uri' => '?string', + 'transcript_uri' => '?string', + 'chapters_uri' => '?string', 'parental_advisory' => '?string', 'number' => '?integer', 'season_number' => '?integer', @@ -170,11 +192,75 @@ class Episode extends Entity } } + /** + * Saves an episode transcript + * + * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $transcript + * + */ + public function setTranscript($transcript) + { + if ( + !empty($transcript) && + (!($transcript instanceof \CodeIgniter\HTTP\Files\UploadedFile) || + $transcript->isValid()) + ) { + helper('media'); + + $this->attributes['transcript_uri'] = save_podcast_media( + $transcript, + $this->getPodcast()->name, + $this->attributes['slug'] . '-transcript' + ); + } + + return $this; + } + + /** + * Saves an episode chapters + * + * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $chapters + * + */ + public function setChapters($chapters) + { + if ( + !empty($chapters) && + (!($chapters instanceof \CodeIgniter\HTTP\Files\UploadedFile) || + $chapters->isValid()) + ) { + helper('media'); + + $this->attributes['chapters_uri'] = save_podcast_media( + $chapters, + $this->getPodcast()->name, + $this->attributes['slug'] . '-chapters' + ); + } + + return $this; + } + public function getEnclosure() { return new \CodeIgniter\Files\File($this->getEnclosureMediaPath()); } + public function getTranscript() + { + return $this->attributes['transcript_uri'] + ? new \CodeIgniter\Files\File($this->getTranscriptMediaPath()) + : null; + } + + public function getChapters() + { + return $this->attributes['chapters_uri'] + ? new \CodeIgniter\Files\File($this->getChaptersMediaPath()) + : null; + } + public function getEnclosureMediaPath() { helper('media'); @@ -182,6 +268,24 @@ class Episode extends Entity return media_path($this->attributes['enclosure_uri']); } + public function getTranscriptMediaPath() + { + helper('media'); + + return $this->attributes['transcript_uri'] + ? media_path($this->attributes['transcript_uri']) + : null; + } + + public function getChaptersMediaPath() + { + helper('media'); + + return $this->attributes['chapters_uri'] + ? media_path($this->attributes['chapters_uri']) + : null; + } + public function getEnclosureUrl() { helper('analytics'); @@ -230,6 +334,20 @@ class Episode extends Entity return $this->getEnclosureUrl() . '?_from=-+Open+Graph+-'; } + public function getTranscriptUrl() + { + return $this->attributes['transcript_uri'] + ? base_url($this->getTranscriptMediaPath()) + : null; + } + + public function getChaptersUrl() + { + return $this->attributes['chapters_uri'] + ? base_url($this->getChaptersMediaPath()) + : null; + } + public function getLink() { return base_url( diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index db7dad440b4f57f6b1c070dab4324458413b0543..e7bc4ffbdab5458198cfb62d0b08ffabae02db18 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -8,6 +8,7 @@ use App\Libraries\SimpleRSSElement; use CodeIgniter\I18n\Time; +use Config\Mimes; /** * Generates the rss feed for a given podcast entity @@ -217,6 +218,35 @@ function get_rss_feed($podcast, $serviceName = '') ); $item->addChild('episodeType', $episode->type, $itunes_namespace); + if ($episode->transcript) { + $transcriptElement = $item->addChild( + 'transcript', + null, + $podcast_namespace + ); + $transcriptElement->addAttribute('url', $episode->transcriptUrl); + $transcriptElement->addAttribute( + 'type', + Mimes::guessTypeFromExtension( + pathinfo($episode->transcript_uri, PATHINFO_EXTENSION) + ) + ); + $transcriptElement->addAttribute( + 'language', + $podcast->language_code + ); + } + + if ($episode->chapters) { + $chaptersElement = $item->addChild( + 'chapters', + null, + $podcast_namespace + ); + $chaptersElement->addAttribute('url', $episode->chaptersUrl); + $chaptersElement->addAttribute('type', 'application/json+chapters'); + } + $episode->is_blocked && $item->addChild('block', 'Yes', $itunes_namespace); } diff --git a/app/Language/en/Episode.php b/app/Language/en/Episode.php index 652bad3ac932aa0cd4d627f4926fcc325a5d23aa..ea045e49d44b773e38932b8a1c2e169be1afc212 100644 --- a/app/Language/en/Episode.php +++ b/app/Language/en/Episode.php @@ -70,6 +70,15 @@ return [ 'block' => 'Episode should be hidden from all platforms', 'block_hint' => 'The episode show or hide status. If you want this episode removed from the Apple directory, toggle this on.', + 'additional_files_section_title' => 'Additional files', + 'additional_files_section_subtitle' => + 'These files may be used by other platforms to provide better experience to your audience.<br />See the {podcastNamespaceLink} for more information.', + 'transcript' => 'Transcript or closed captions', + 'transcript_hint' => 'Allowed formats are txt, html, srt or json.', + 'transcript_delete' => 'Delete transcript', + 'chapters' => 'Chapters', + 'chapters_hint' => 'File should be in JSON Chapters Format.', + 'chapters_delete' => 'Delete chapters', 'submit_create' => 'Create episode', 'submit_edit' => 'Save episode', ], diff --git a/app/Language/fr/Episode.php b/app/Language/fr/Episode.php index a98e63b974bbf45058fd1d21db64119c2af939e8..25911e2a5d87f793689ba66ae272f9fa86d49183 100644 --- a/app/Language/fr/Episode.php +++ b/app/Language/fr/Episode.php @@ -70,6 +70,16 @@ return [ 'block' => 'L’épisode doit être masqué de toutes les plateformes', 'block_hint' => 'La visibilité de l’épisode. Si vous souhaitez retirer cet épisode de l’index Apple, activez ce champ.', + 'additional_files_section_title' => 'Fichiers additionels', + 'additional_files_section_subtitle' => + 'Ces fichiers pourront être utilisées par d’autres plate-formes pour procurer une meilleure expérience à vos auditeurs.<br />Consulter le {podcastNamespaceLink} pour plus d’informations.', + 'transcript' => 'Transcription ou sous-titrage', + 'transcript_hint' => + 'Les formats autorisés sont txt, html, srt ou json.', + 'transcript_delete' => 'Supprimer la transcription', + 'chapters' => 'Chapitrage', + 'chapters_hint' => 'Le fichier doit être en "JSON Chapters Format".', + 'chapters_delete' => 'Supprimer le chaptrage', 'submit_create' => 'Créer l’épisode', 'submit_edit' => 'Enregistrer l’épisode', ], diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 9ce7ce0cf29e0a4ee6ddc635452dbdca57b0cb59..c60803a215dada75aef0f7ab20ffb368446959d5 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -28,6 +28,8 @@ class EpisodeModel extends Model 'description_markdown', 'description_html', 'image_uri', + 'transcript_uri', + 'chapters_uri', 'parental_advisory', 'number', 'season_number', diff --git a/app/Views/_assets/icons/file.svg b/app/Views/_assets/icons/file.svg new file mode 100644 index 0000000000000000000000000000000000000000..dcddb3965b71517e06e85c2a1265f18062a0b6d6 --- /dev/null +++ b/app/Views/_assets/icons/file.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M19 22H5a3 3 0 0 1-3-3V3a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v12h4v4a3 3 0 0 1-3 3zm-1-5v2a1 1 0 0 0 2 0v-2h-2zm-2 3V4H4v15a1 1 0 0 0 1 1h11zM6 7h8v2H6V7zm0 4h8v2H6v-2zm0 4h5v2H6v-2z"/></svg> \ No newline at end of file diff --git a/app/Views/admin/episode/create.php b/app/Views/admin/episode/create.php index 6d1e50c96edd403134d0c4823a23c12ab0a53876..d5e39664a978247d08c8f8b1525dbbb1b1dc7656 100644 --- a/app/Views/admin/episode/create.php +++ b/app/Views/admin/episode/create.php @@ -264,6 +264,40 @@ <?= form_section_close() ?> +<?= form_section( + lang('Episode.form.additional_files_section_title'), + lang('Episode.form.additional_files_section_subtitle') +) ?> +<?= form_label( + lang('Episode.form.transcript'), + 'transcript', + [], + lang('Episode.form.transcript_hint'), + true +) ?> +<?= form_input([ + 'id' => 'transcript', + 'name' => 'transcript', + 'class' => 'form-input mb-4', + 'type' => 'file', + 'accept' => '.txt,.html,.srt,.json', +]) ?> +<?= form_label( + lang('Episode.form.chapters'), + 'chapters', + [], + lang('Episode.form.chapters_hint'), + true +) ?> +<?= form_input([ + 'id' => 'chapters', + 'name' => 'chapters', + 'class' => 'form-input mb-4', + 'type' => 'file', + 'accept' => '.json', +]) ?> +<?= form_section_close() ?> + <?= button( lang('Episode.form.submit_create'), null, diff --git a/app/Views/admin/episode/edit.php b/app/Views/admin/episode/edit.php index 2b5db05fdf3caf0c8b486eaf9270e6c28e857859..541a37897bc1a0e93f856613ea2128216865f410 100644 --- a/app/Views/admin/episode/edit.php +++ b/app/Views/admin/episode/edit.php @@ -136,7 +136,6 @@ </label> <?= form_radio( ['id' => 'bonus', 'name' => 'type', 'class' => 'form-radio-btn'], - 'bonus', old('type') ? old('type') === 'bonus' : $episode->type === 'bonus' ) ?> @@ -273,6 +272,91 @@ old('block', $episode->is_blocked) ) ?> +<?= form_section_close() ?> +<?= form_section( + lang('Episode.form.additional_files_section_title'), + lang('Episode.form.additional_files_section_subtitle') +) ?> +<div class="flex flex-col flex-1"> +<?= form_label( + lang('Episode.form.transcript'), + 'transcript', + [], + lang('Episode.form.transcript_hint'), + true +) ?> +<?php if ($episode->transcript): ?> + <div class="flex justify-between"> + <?= anchor( + $episode->transcriptUrl, + icon('file', 'mr-2') . $episode->transcript, + [ + 'class' => 'inline-flex items-center text-xs', + 'target' => '_blank', + 'rel' => 'noreferrer noopener', + ] + ) . + anchor( + route_to('transcript-delete', $podcast->id, $episode->id), + icon('delete-bin', 'mx-auto'), + [ + 'class' => + 'p-1 bg-red-200 rounded-full text-red-700 hover:text-red-900', + 'data-toggle' => 'tooltip', + 'data-placement' => 'bottom', + 'title' => lang('Episode.form.transcript_delete'), + ] + ) ?> + </div> +<?php endif; ?> +<?= form_input([ + 'id' => 'transcript', + 'name' => 'transcript', + 'class' => 'form-input mb-4', + 'type' => 'file', + 'accept' => '.txt,.html,.srt,.json', +]) ?> +</div> +<div class="flex flex-col flex-1"> +<?= form_label( + lang('Episode.form.chapters'), + 'chapters', + [], + lang('Episode.form.chapters_hint'), + true +) ?> +<?php if ($episode->chapters): ?> + <div class="flex justify-between"> + <?= anchor( + $episode->chaptersUrl, + icon('file', 'mr-2') . $episode->chapters, + [ + 'class' => 'inline-flex items-center text-xs', + 'target' => '_blank', + 'rel' => 'noreferrer noopener', + ] + ) . + anchor( + route_to('chapters-delete', $podcast->id, $episode->id), + icon('delete-bin', 'mx-auto'), + [ + 'class' => + 'p-1 bg-red-200 rounded-full text-red-700 hover:text-red-900', + 'data-toggle' => 'tooltip', + 'data-placement' => 'bottom', + 'title' => lang('Episode.form.chapters_delete'), + ] + ) ?> + </div> +<?php endif; ?> +<?= form_input([ + 'id' => 'chapters', + 'name' => 'chapters', + 'class' => 'form-input mb-4', + 'type' => 'file', + 'accept' => '.json', +]) ?> +</div> <?= form_section_close() ?> <?= button(