feat(rss): add transcript and chapters support

Close #72, #82
This commit is contained in:
Benjamin Bellamy 2020-11-24 20:18:08 +00:00
parent b9c8008062
commit e769d83a93
12 changed files with 364 additions and 2 deletions

View File

@ -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'],
]; ];

View File

@ -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',
]
);
}); });
}); });

View File

@ -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
'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
'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
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
]); ]);
} }
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);

View File

@ -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'],

View File

@ -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
*/ */
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
'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
} }
} }
/**
* 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
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
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(

View File

@ -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 = '')
); );
$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);
} }

View File

@ -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',
], ],

View File

@ -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 lindex Apple, activez ce champ.', 'La visibilité de lépisode. Si vous souhaitez retirer cet épisode de lindex Apple, activez ce champ.',
'additional_files_section_title' => 'Fichiers additionels',
'additional_files_section_subtitle' =>
'Ces fichiers pourront être utilisées par dautres plate-formes pour procurer une meilleure expérience à vos auditeurs.<br />Consulter le {podcastNamespaceLink} pour plus dinformations.',
'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',
], ],

View File

@ -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',

View File

@ -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>

After

Width:  |  Height:  |  Size: 314 B

View File

@ -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,

View File

@ -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 @@
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(