Skip to content
Snippets Groups Projects
Commit e769d83a authored by Benjamin Bellamy's avatar Benjamin Bellamy :speech_balloon:
Browse files

feat(rss): add transcript and chapters support

Close #72, #82
parent b9c80080
Branches
Tags
No related merge requests found
...@@ -307,7 +307,7 @@ class Mimes ...@@ -307,7 +307,7 @@ class Mimes
], ],
'svg' => ['image/svg+xml', 'application/xml', 'text/xml'], 'svg' => ['image/svg+xml', 'application/xml', 'text/xml'],
'vcf' => 'text/x-vcard', 'vcf' => 'text/x-vcard',
'srt' => ['text/srt', 'text/plain'], 'srt' => ['text/srt', 'text/plain', 'application/octet-stream'],
'vtt' => ['text/vtt', 'text/plain'], 'vtt' => ['text/vtt', 'text/plain'],
'ico' => ['image/x-icon', 'image/x-ico', 'image/vnd.microsoft.icon'], 'ico' => ['image/x-icon', 'image/x-ico', 'image/vnd.microsoft.icon'],
]; ];
......
...@@ -237,6 +237,22 @@ $routes->group( ...@@ -237,6 +237,22 @@ $routes->group(
'as' => 'episode-delete', 'as' => 'episode-delete',
'filter' => 'permission:podcast_episodes-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',
]
);
}); });
}); });
......
...@@ -96,6 +96,8 @@ class Episode extends BaseController ...@@ -96,6 +96,8 @@ class Episode extends BaseController
'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]', 'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
'image' => 'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[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', 'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
]; ];
...@@ -114,6 +116,8 @@ class Episode extends BaseController ...@@ -114,6 +116,8 @@ class Episode extends BaseController
'enclosure' => $this->request->getFile('enclosure'), 'enclosure' => $this->request->getFile('enclosure'),
'description_markdown' => $this->request->getPost('description'), 'description_markdown' => $this->request->getPost('description'),
'image' => $this->request->getFile('image'), 'image' => $this->request->getFile('image'),
'transcript' => $this->request->getFile('transcript'),
'chapters' => $this->request->getFile('chapters'),
'parental_advisory' => 'parental_advisory' =>
$this->request->getPost('parental_advisory') !== 'undefined' $this->request->getPost('parental_advisory') !== 'undefined'
? $this->request->getPost('parental_advisory') ? $this->request->getPost('parental_advisory')
...@@ -189,6 +193,8 @@ class Episode extends BaseController ...@@ -189,6 +193,8 @@ class Episode extends BaseController
'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty', 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
'image' => 'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[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', 'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
]; ];
...@@ -231,6 +237,14 @@ class Episode extends BaseController ...@@ -231,6 +237,14 @@ class Episode extends BaseController
if ($image) { if ($image) {
$this->episode->image = $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(); $episodeModel = new EpisodeModel();
...@@ -262,6 +276,40 @@ class Episode extends BaseController ...@@ -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() public function delete()
{ {
(new EpisodeModel())->delete($this->episode->id); (new EpisodeModel())->delete($this->episode->id);
......
...@@ -73,6 +73,16 @@ class AddEpisodes extends Migration ...@@ -73,6 +73,16 @@ class AddEpisodes extends Migration
'constraint' => 255, 'constraint' => 255,
'null' => true, 'null' => true,
], ],
'transcript_uri' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
],
'chapters_uri' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
],
'parental_advisory' => [ 'parental_advisory' => [
'type' => 'ENUM', 'type' => 'ENUM',
'constraint' => ['clean', 'explicit'], 'constraint' => ['clean', 'explicit'],
......
...@@ -35,6 +35,16 @@ class Episode extends Entity ...@@ -35,6 +35,16 @@ class Episode extends Entity
*/ */
protected $enclosure; protected $enclosure;
/**
* @var \CodeIgniter\Files\File
*/
protected $transcript;
/**
* @var \CodeIgniter\Files\File
*/
protected $chapters;
/** /**
* @var string * @var string
*/ */
...@@ -55,6 +65,16 @@ class Episode extends Entity ...@@ -55,6 +65,16 @@ class Episode extends Entity
*/ */
protected $enclosure_opengraph_url; 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 * Holds text only description, striped of any markdown or html special characters
* *
...@@ -86,6 +106,8 @@ class Episode extends Entity ...@@ -86,6 +106,8 @@ class Episode extends Entity
'description_markdown' => 'string', 'description_markdown' => 'string',
'description_html' => 'string', 'description_html' => 'string',
'image_uri' => '?string', 'image_uri' => '?string',
'transcript_uri' => '?string',
'chapters_uri' => '?string',
'parental_advisory' => '?string', 'parental_advisory' => '?string',
'number' => '?integer', 'number' => '?integer',
'season_number' => '?integer', 'season_number' => '?integer',
...@@ -170,11 +192,75 @@ class Episode extends Entity ...@@ -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() public function getEnclosure()
{ {
return new \CodeIgniter\Files\File($this->getEnclosureMediaPath()); 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() public function getEnclosureMediaPath()
{ {
helper('media'); helper('media');
...@@ -182,6 +268,24 @@ class Episode extends Entity ...@@ -182,6 +268,24 @@ class Episode extends Entity
return media_path($this->attributes['enclosure_uri']); 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() public function getEnclosureUrl()
{ {
helper('analytics'); helper('analytics');
...@@ -230,6 +334,20 @@ class Episode extends Entity ...@@ -230,6 +334,20 @@ class Episode extends Entity
return $this->getEnclosureUrl() . '?_from=-+Open+Graph+-'; 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() public function getLink()
{ {
return base_url( return base_url(
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
use App\Libraries\SimpleRSSElement; use App\Libraries\SimpleRSSElement;
use CodeIgniter\I18n\Time; use CodeIgniter\I18n\Time;
use Config\Mimes;
/** /**
* Generates the rss feed for a given podcast entity * Generates the rss feed for a given podcast entity
...@@ -217,6 +218,35 @@ function get_rss_feed($podcast, $serviceName = '') ...@@ -217,6 +218,35 @@ function get_rss_feed($podcast, $serviceName = '')
); );
$item->addChild('episodeType', $episode->type, $itunes_namespace); $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 && $episode->is_blocked &&
$item->addChild('block', 'Yes', $itunes_namespace); $item->addChild('block', 'Yes', $itunes_namespace);
} }
......
...@@ -70,6 +70,15 @@ return [ ...@@ -70,6 +70,15 @@ return [
'block' => 'Episode should be hidden from all platforms', 'block' => 'Episode should be hidden from all platforms',
'block_hint' => 'block_hint' =>
'The episode show or hide status. If you want this episode removed from the Apple directory, toggle this on.', '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_create' => 'Create episode',
'submit_edit' => 'Save episode', 'submit_edit' => 'Save episode',
], ],
......
...@@ -70,6 +70,16 @@ return [ ...@@ -70,6 +70,16 @@ return [
'block' => 'L’épisode doit être masqué de toutes les plateformes', 'block' => 'L’épisode doit être masqué de toutes les plateformes',
'block_hint' => 'block_hint' =>
'La visibilité de l’épisode. Si vous souhaitez retirer cet épisode de l’index Apple, activez ce champ.', '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_create' => 'Créer l’épisode',
'submit_edit' => 'Enregistrer l’épisode', 'submit_edit' => 'Enregistrer l’épisode',
], ],
......
...@@ -28,6 +28,8 @@ class EpisodeModel extends Model ...@@ -28,6 +28,8 @@ class EpisodeModel extends Model
'description_markdown', 'description_markdown',
'description_html', 'description_html',
'image_uri', 'image_uri',
'transcript_uri',
'chapters_uri',
'parental_advisory', 'parental_advisory',
'number', 'number',
'season_number', 'season_number',
......
<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
...@@ -264,6 +264,40 @@ ...@@ -264,6 +264,40 @@
<?= form_section_close() ?> <?= 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( <?= button(
lang('Episode.form.submit_create'), lang('Episode.form.submit_create'),
null, null,
......
...@@ -136,7 +136,6 @@ ...@@ -136,7 +136,6 @@
</label> </label>
<?= form_radio( <?= form_radio(
['id' => 'bonus', 'name' => 'type', 'class' => 'form-radio-btn'], ['id' => 'bonus', 'name' => 'type', 'class' => 'form-radio-btn'],
'bonus', 'bonus',
old('type') ? old('type') === 'bonus' : $episode->type === 'bonus' old('type') ? old('type') === 'bonus' : $episode->type === 'bonus'
) ?> ) ?>
...@@ -273,6 +272,91 @@ ...@@ -273,6 +272,91 @@
old('block', $episode->is_blocked) 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() ?> <?= form_section_close() ?>
<?= button( <?= button(
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment