Commit 6b34617d authored by Benjamin Bellamy's avatar Benjamin Bellamy 💬
Browse files

feat(rss): add soundbites according to the podcastindex specs

Closes #83
parent 0571a075
Pipeline #551 passed with stage
in 4 minutes and 48 seconds
......@@ -253,6 +253,29 @@ $routes->group(
'filter' => 'permission:podcast_episodes-edit',
]
);
$routes->get(
'soundbites',
'Episode::soundbitesEdit/$1/$2',
[
'as' => 'soundbites-edit',
'filter' => 'permission:podcast_episodes-edit',
]
);
$routes->post(
'soundbites',
'Episode::soundbitesAttemptEdit/$1/$2',
[
'filter' => 'permission:podcast_episodes-edit',
]
);
$routes->add(
'soundbites/(:num)/delete',
'Episode::soundbiteDelete/$1/$2/$3',
[
'as' => 'soundbite-delete',
'filter' => 'permission:podcast_episodes-edit',
]
);
});
});
......
......@@ -10,6 +10,7 @@ namespace App\Controllers\Admin;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use App\Models\SoundbiteModel;
use CodeIgniter\I18n\Time;
class Episode extends BaseController
......@@ -24,6 +25,11 @@ class Episode extends BaseController
*/
protected $episode;
/**
* @var \App\Entities\Soundbite|null
*/
protected $soundbites;
public function _remap($method, ...$params)
{
$this->podcast = (new PodcastModel())->getPodcastById($params[0]);
......@@ -39,9 +45,12 @@ class Episode extends BaseController
) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
unset($params[1]);
unset($params[0]);
}
return $this->$method();
return $this->$method(...$params);
}
public function list()
......@@ -316,4 +325,89 @@ class Episode extends BaseController
return redirect()->route('episode-list', [$this->podcast->id]);
}
public function soundbitesEdit()
{
helper(['form']);
$data = [
'podcast' => $this->podcast,
'episode' => $this->episode,
];
replace_breadcrumb_params([
0 => $this->podcast->title,
1 => $this->episode->title,
]);
return view('admin/episode/soundbites', $data);
}
public function soundbitesAttemptEdit()
{
$soundbites_array = $this->request->getPost('soundbites_array');
$rules = [
'soundbites_array.0.start_time' =>
'permit_empty|required_with[soundbites_array.0.duration]|decimal|greater_than_equal_to[0]',
'soundbites_array.0.duration' =>
'permit_empty|required_with[soundbites_array.0.start_time]|decimal|greater_than_equal_to[0]',
];
foreach ($soundbites_array as $soundbite_id => $soundbite) {
$rules += [
"soundbites_array.{$soundbite_id}.start_time" => 'required|decimal|greater_than_equal_to[0]',
"soundbites_array.{$soundbite_id}.duration" => 'required|decimal|greater_than_equal_to[0]',
];
}
if (!$this->validate($rules)) {
return redirect()
->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
foreach ($soundbites_array as $soundbite_id => $soundbite) {
if (
!empty($soundbite['start_time']) &&
!empty($soundbite['duration'])
) {
$data = [
'podcast_id' => $this->podcast->id,
'episode_id' => $this->episode->id,
'start_time' => $soundbite['start_time'],
'duration' => $soundbite['duration'],
'label' => $soundbite['label'],
'updated_by' => user()->id,
];
if ($soundbite_id == 0) {
$data += ['created_by' => user()->id];
} else {
$data += ['id' => $soundbite_id];
}
$soundbiteModel = new SoundbiteModel();
if (!$soundbiteModel->save($data)) {
return redirect()
->back()
->withInput()
->with('errors', $soundbiteModel->errors());
}
}
}
return redirect()->route('soundbites-edit', [
$this->podcast->id,
$this->episode->id,
]);
}
public function soundbiteDelete($soundbiteId)
{
(new SoundbiteModel())->deleteSoundbite(
$this->podcast->id,
$this->episode->id,
$soundbiteId
);
return redirect()->route('soundbites-edit', [
$this->podcast->id,
$this->episode->id,
]);
}
}
<?php
/**
* Class AddSoundbites
* Creates soundbites table in database
*
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSoundbites extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'INT',
'unsigned' => true,
],
'episode_id' => [
'type' => 'INT',
'unsigned' => true,
],
'start_time' => [
'type' => 'FLOAT',
],
'duration' => [
'type' => 'FLOAT',
],
'label' => [
'type' => 'VARCHAR',
'constraint' => 128,
'null' => true,
],
'created_by' => [
'type' => 'INT',
'unsigned' => true,
],
'updated_by' => [
'type' => 'INT',
'unsigned' => true,
],
'created_at' => [
'type' => 'DATETIME',
],
'updated_at' => [
'type' => 'DATETIME',
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['episode_id', 'start_time', 'duration']);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->addForeignKey('episode_id', 'episodes', 'id');
$this->forge->addForeignKey('created_by', 'users', 'id');
$this->forge->addForeignKey('updated_by', 'users', 'id');
$this->forge->createTable('soundbites');
}
public function down()
{
$this->forge->dropTable('soundbites');
}
}
......@@ -19,6 +19,7 @@ class Category extends Entity
protected $parent;
protected $casts = [
'id' => 'integer',
'parent_id' => 'integer',
'code' => 'string',
'apple_category' => 'string',
......
......@@ -9,6 +9,7 @@
namespace App\Entities;
use App\Models\PodcastModel;
use App\Models\SoundbiteModel;
use CodeIgniter\Entity;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
......@@ -75,6 +76,11 @@ class Episode extends Entity
*/
protected $chapters_url;
/**
* @var \App\Entities\Soundbite[]
*/
protected $soundbites;
/**
* Holds text only description, striped of any markdown or html special characters
*
......@@ -95,6 +101,7 @@ class Episode extends Entity
];
protected $casts = [
'id' => 'integer',
'guid' => 'string',
'slug' => 'string',
'title' => 'string',
......@@ -348,6 +355,29 @@ class Episode extends Entity
: null;
}
/**
* Returns the episode’s soundbites
*
* @return \App\Entities\Episode[]
*/
public function getSoundbites()
{
if (empty($this->id)) {
throw new \RuntimeException(
'Episode must be created before getting soundbites.'
);
}
if (empty($this->soundbites)) {
$this->soundbites = (new SoundbiteModel())->getEpisodeSoundbites(
$this->getPodcast()->id,
$this->id
);
}
return $this->soundbites;
}
public function getLink()
{
return base_url(
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use CodeIgniter\Entity;
class Soundbite extends Entity
{
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'episode_id' => 'integer',
'start_time' => 'float',
'duration' => 'float',
'label' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
];
public function setCreatedBy(\App\Entities\User $user)
{
$this->attributes['created_by'] = $user->id;
return $this;
}
public function setUpdatedBy(\App\Entities\User $user)
{
$this->attributes['updated_by'] = $user->id;
return $this;
}
}
......@@ -29,6 +29,7 @@ class User extends \Myth\Auth\Entities\User
* when they are accessed.
*/
protected $casts = [
'id' => 'integer',
'active' => 'boolean',
'force_pass_reset' => 'boolean',
'podcast_role' => '?string',
......
......@@ -255,6 +255,19 @@ function get_rss_feed($podcast, $serviceSlug = '')
$chaptersElement->addAttribute('type', 'application/json+chapters');
}
foreach ($episode->soundbites as $soundbite) {
$soundbiteElement = $item->addChild(
'soundbite',
empty($soundbite->label) ? null : $soundbite->label,
$podcast_namespace
);
$soundbiteElement->addAttribute(
'start_time',
$soundbite->start_time
);
$soundbiteElement->addAttribute('duration', $soundbite->duration);
}
$episode->is_blocked &&
$item->addChild('block', 'Yes', $itunes_namespace);
}
......
......@@ -30,4 +30,5 @@ return [
'players' => 'players',
'listening-time' => 'listening time',
'time-periods' => 'time periods',
'soundbites' => 'soundbites',
];
......@@ -82,4 +82,23 @@ return [
'submit_create' => 'Create episode',
'submit_edit' => 'Save episode',
],
'soundbites' => 'Soundbites',
'soundbites_form' => [
'title' => 'Edit soundbites',
'info_section_title' => 'Episode soundbites',
'info_section_subtitle' => 'Add, edit or delete soundbites',
'start_time' => 'Start',
'start_time_hint' =>
'The first second of the soundbite, it can be a decimal number.',
'duration' => 'Duration',
'duration_hint' =>
'The duration of the soundbite (in seconds), it can be a decimal number.',
'label' => 'Label',
'label_hint' => 'Text that will be displayed.',
'play' => 'Play soundbite',
'delete' => 'Delete soundbite',
'bookmark' =>
'Click while playing to get current position, click again to get duration.',
'submit_edit' => 'Save all soundbites',
],
];
......@@ -30,4 +30,5 @@ return [
'players' => 'lecteurs',
'listening-time' => 'drée d’écoute',
'time-periods' => 'périodes',
'soundbites' => 'extraits sonores',
];
......@@ -83,4 +83,24 @@ return [
'submit_create' => 'Créer l’épisode',
'submit_edit' => 'Enregistrer l’épisode',
],
'soundbites' => 'Extraits sonores',
'soundbites_form' => [
'title' => 'Modifier les extraits sonores',
'info_section_title' => 'Extraits sonores de l’épisode',
'info_section_subtitle' =>
'Ajouter, modifier ou supprimer des extraits sonores',
'start_time' => 'Début',
'start_time_hint' =>
'La première seconde de l’extrait sonore, cela peut être un nombre décimal.',
'duration' => 'Durée',
'duration_hint' =>
'La durée de l’extrait sonore (en secondes), cela peut être un nombre décimal.',
'label' => 'Libellé',
'label_hint' => 'Texte qui sera affiché.',
'play' => 'Écouter l’extrait sonore',
'delete' => 'Supprimer l’extrait sonore',
'bookmark' =>
'Cliquez pour récupérer la position actuelle, cliquez à nouveau pour récupérer la durée.',
'submit_edit' => 'Enregistrer tous les extraits sonores',
],
];
<?php
/**
* Class SoundbiteModel
* Model for podcasts_soundbites table in database
*
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Models;
use CodeIgniter\Model;
class SoundbiteModel extends Model
{
protected $table = 'soundbites';
protected $primaryKey = 'id';
protected $allowedFields = [
'podcast_id',
'episode_id',
'label',
'start_time',
'duration',
'created_by',
'updated_by',
];
protected $returnType = \App\Entities\Soundbite::class;
protected $useSoftDeletes = false;
protected $useTimestamps = true;
protected $afterInsert = ['clearCache'];
protected $afterUpdate = ['clearCache'];
protected $beforeDelete = ['clearCache'];
public function deleteSoundbite($podcastId, $episodeId, $soundbiteId)
{
return $this->delete([
'podcast_id' => $podcastId,
'episode_id' => $episodeId,
'id' => $soundbiteId,
]);
}
/**
* Gets all soundbites for an episode
*
* @param int $podcastId
* @param int $episodeId
*
* @return \App\Entities\Soundbite[]
*/
public function getEpisodeSoundbites(int $podcastId, int $episodeId): array
{
if (!($found = cache("episode{$episodeId}_soundbites"))) {
$found = $this->where([
'episode_id' => $episodeId,
'podcast_id' => $podcastId,
])
->orderBy('start_time')
->findAll();
cache()->save("episode{$episodeId}_soundbites", $found, DECADE);
}
return $found;
}
public function clearCache(array $data)
{
$episode = (new EpisodeModel())->find(
isset($data['data'])
? $data['data']['episode_id']
: $data['id']['episode_id']
);
cache()->delete("episode{$episode->id}_soundbites");
// delete cache for rss feed
cache()->delete("podcast{$episode->id}_feed");
foreach (\Opawg\UserAgentsPhp\UserAgentsRSS::$db as $service) {
cache()->delete(
"podcast{$episode->podcast->id}_feed_{$service['slug']}"
);
}
$supportedLocales = config('App')->supportedLocales;
foreach ($supportedLocales as $locale) {
cache()->delete(
"page_podcast{$episode->podcast->id}_episode{$episode->id}_{$locale}"
);
}
return $data;
}
}
<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="M5 2h14a1 1 0 0 1 1 1v19.143a.5.5 0 0 1-.766.424L12 18.03l-7.234 4.536A.5.5 0 0 1 4 22.143V3a1 1 0 0 1 1-1z"/></svg>
\ No newline at end of file
<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="M7.752 5.439l10.508 6.13a.5.5 0 0 1 0 .863l-10.508 6.13A.5.5 0 0 1 7 18.128V5.871a.5.5 0 0 1 .752-.432z"/></svg>
\ No newline at end of file
<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="M17.618 5.968l1.453-1.453 1.414 1.414-1.453 1.453a9 9 0 1 1-1.414-1.414zM11 8v6h2V8h-2zM8 1h8v2H8V1z"/></svg>
\ No newline at end of file
......@@ -3,7 +3,7 @@ import am4geodata_worldLow from "@amcharts/amcharts4-geodata/worldLow";
import * as am4charts from "@amcharts/amcharts4/charts";
import * as am4core from "@amcharts/amcharts4/core";
import * as am4maps from "@amcharts/amcharts4/maps";
import * as am4plugins_sliceGrouper from "@amcharts/amcharts4/plugins/sliceGrouper";
import * as am4plugins_sliceGrouper from "@amcharts/amcharts4/plugins/sliceGrouper";
import am4themes_material from "@amcharts/amcharts4/themes/material";
const drawPieChart = (chartDivId: string, dataUrl: string | null): void => {
......@@ -21,7 +21,9 @@ const drawPieChart = (chartDivId: string, dataUrl: string | null): void => {
chart.dataSource.parser.options.emptyAs = 0;
// Add and configure Series
const pieSeries = chart.series.push(new am4charts.PieSeries());
const grouper = pieSeries.plugins.push(new am4plugins_sliceGrouper.SliceGrouper());
const grouper = pieSeries.plugins.push(
new am4plugins_sliceGrouper.SliceGrouper()
);
grouper.limit = 9;
grouper.groupName = "- Other -";
grouper.clickBehavior = "break";
......@@ -95,13 +97,12 @@ const drawBarChart = (chartDivId: string, dataUrl: string | null): void => {
series.dataFields.categoryX = "labels";
series.name = "Hits";
series.columns.template.tooltipText = "{valueY} hits";
series.columns.template.fillOpacity = .8;
series.columns.template.fillOpacity = 0.8;
const columnTemplate = series.columns.template;
columnTemplate.strokeWidth = 2;
columnTemplate.strokeOpacity = 1;
};
const drawXYDurationChart = (
chartDivId: string,
dataUrl: string | null
......
let timeout: number | null = null;
const playSoundbite = (
audioPlayer: HTMLAudioElement,
startTime: number,
duration: number
): void => {
audioPlayer.currentTime = startTime;
if (duration > 0) {
audioPlayer.play();
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
timeout = window.setTimeout(() => {
audioPlayer.pause();
timeout = null;
}, duration * 1000);
}
};
const Soundbites = (): void => {
const audioPlayer: HTMLAudioElement | null = document.querySelector("audio");
if (audioPlayer) {
const soundbiteButton: HTMLButtonElement | null = document.querySelector(
"button[data-type='get-soundbite']"
);
if (soundbiteButton) {
const startTimeField: HTMLInputElement | null = document.querySelector(
`input[name="${soundbiteButton.dataset.startTimeFieldName}"]`
);
const durationField: HTMLInputElement | null = document.querySelector(
`input[name="${soundbiteButton.dataset.durationFieldName}"]`
);