Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • alpha
  • beta
  • develop
  • docs/fix-readme
  • docs/update-vitepress
  • draft/rss-feed
  • feat/dashboard
  • feat/episodes-page-ux
  • feat/generator-user-agent
  • feat/headliner
  • feat/new-languages
  • feat/plugins
  • fix/federation
  • fix/forms-ux
  • i18n
  • main
  • next
  • refactor/transcripts
  • v1.0.0
  • v1.0.0-alpha.1
  • v1.0.0-alpha.10
  • v1.0.0-alpha.11
  • v1.0.0-alpha.12
  • v1.0.0-alpha.13
  • v1.0.0-alpha.14
  • v1.0.0-alpha.15
  • v1.0.0-alpha.16
  • v1.0.0-alpha.17
  • v1.0.0-alpha.18
  • v1.0.0-alpha.19
  • v1.0.0-alpha.2
  • v1.0.0-alpha.20
  • v1.0.0-alpha.21
  • v1.0.0-alpha.22
  • v1.0.0-alpha.23
  • v1.0.0-alpha.24
  • v1.0.0-alpha.25
  • v1.0.0-alpha.26
  • v1.0.0-alpha.27
  • v1.0.0-alpha.28
  • v1.0.0-alpha.29
  • v1.0.0-alpha.3
  • v1.0.0-alpha.30
  • v1.0.0-alpha.31
  • v1.0.0-alpha.32
  • v1.0.0-alpha.33
  • v1.0.0-alpha.34
  • v1.0.0-alpha.35
  • v1.0.0-alpha.36
  • v1.0.0-alpha.37
  • v1.0.0-alpha.38
  • v1.0.0-alpha.39
  • v1.0.0-alpha.4
  • v1.0.0-alpha.40
  • v1.0.0-alpha.41
  • v1.0.0-alpha.42
  • v1.0.0-alpha.43
  • v1.0.0-alpha.44
  • v1.0.0-alpha.45
  • v1.0.0-alpha.46
  • v1.0.0-alpha.47
  • v1.0.0-alpha.48
  • v1.0.0-alpha.49
  • v1.0.0-alpha.5
  • v1.0.0-alpha.50
  • v1.0.0-alpha.51
  • v1.0.0-alpha.52
  • v1.0.0-alpha.53
  • v1.0.0-alpha.54
  • v1.0.0-alpha.55
  • v1.0.0-alpha.56
  • v1.0.0-alpha.57
  • v1.0.0-alpha.58
  • v1.0.0-alpha.59
  • v1.0.0-alpha.6
  • v1.0.0-alpha.60
  • v1.0.0-alpha.61
  • v1.0.0-alpha.62
  • v1.0.0-alpha.63
  • v1.0.0-alpha.64
  • v1.0.0-alpha.65
  • v1.0.0-alpha.66
  • v1.0.0-alpha.67
  • v1.0.0-alpha.68
  • v1.0.0-alpha.69
  • v1.0.0-alpha.7
  • v1.0.0-alpha.70
  • v1.0.0-alpha.71
  • v1.0.0-alpha.72
  • v1.0.0-alpha.73
  • v1.0.0-alpha.74
  • v1.0.0-alpha.75
  • v1.0.0-alpha.76
  • v1.0.0-alpha.77
  • v1.0.0-alpha.78
  • v1.0.0-alpha.79
  • v1.0.0-alpha.8
  • v1.0.0-alpha.80
  • v1.0.0-alpha.9
  • v1.0.0-beta.1
  • v1.0.0-beta.10
  • v1.0.0-beta.11
  • v1.0.0-beta.12
  • v1.0.0-beta.13
  • v1.0.0-beta.14
  • v1.0.0-beta.15
  • v1.0.0-beta.16
  • v1.0.0-beta.17
  • v1.0.0-beta.18
  • v1.0.0-beta.19
  • v1.0.0-beta.2
  • v1.0.0-beta.20
  • v1.0.0-beta.21
  • v1.0.0-beta.22
  • v1.0.0-beta.23
  • v1.0.0-beta.24
  • v1.0.0-beta.3
  • v1.0.0-beta.4
118 results

Target

Select target project
  • adaures/castopod
  • mkljczk/castopod-host
  • spaetz/castopod-host
  • PatrykMis/castopod
  • jonas/castopod
  • ajeremias/castopod
  • misuzu/castopod
  • KrzysztofDomanczyk/castopod
  • Behel/castopod
  • nebulon/castopod
  • ewen/castopod
  • NeoluxConsulting/castopod
  • nateritter/castopod-og
  • prcutler/castopod
14 results
Select Git revision
  • alpha
  • beta
  • develop
  • docs/fix-readme
  • docs/update-vitepress
  • draft/rss-feed
  • feat/dashboard
  • feat/episodes-page-ux
  • feat/generator-user-agent
  • feat/headliner
  • feat/new-languages
  • feat/plugins
  • fix/federation
  • fix/forms-ux
  • i18n
  • main
  • next
  • refactor/transcripts
  • v1.0.0
  • v1.0.0-alpha.1
  • v1.0.0-alpha.10
  • v1.0.0-alpha.11
  • v1.0.0-alpha.12
  • v1.0.0-alpha.13
  • v1.0.0-alpha.14
  • v1.0.0-alpha.15
  • v1.0.0-alpha.16
  • v1.0.0-alpha.17
  • v1.0.0-alpha.18
  • v1.0.0-alpha.19
  • v1.0.0-alpha.2
  • v1.0.0-alpha.20
  • v1.0.0-alpha.21
  • v1.0.0-alpha.22
  • v1.0.0-alpha.23
  • v1.0.0-alpha.24
  • v1.0.0-alpha.25
  • v1.0.0-alpha.26
  • v1.0.0-alpha.27
  • v1.0.0-alpha.28
  • v1.0.0-alpha.29
  • v1.0.0-alpha.3
  • v1.0.0-alpha.30
  • v1.0.0-alpha.31
  • v1.0.0-alpha.32
  • v1.0.0-alpha.33
  • v1.0.0-alpha.34
  • v1.0.0-alpha.35
  • v1.0.0-alpha.36
  • v1.0.0-alpha.37
  • v1.0.0-alpha.38
  • v1.0.0-alpha.39
  • v1.0.0-alpha.4
  • v1.0.0-alpha.40
  • v1.0.0-alpha.41
  • v1.0.0-alpha.42
  • v1.0.0-alpha.43
  • v1.0.0-alpha.44
  • v1.0.0-alpha.45
  • v1.0.0-alpha.46
  • v1.0.0-alpha.47
  • v1.0.0-alpha.48
  • v1.0.0-alpha.49
  • v1.0.0-alpha.5
  • v1.0.0-alpha.50
  • v1.0.0-alpha.51
  • v1.0.0-alpha.52
  • v1.0.0-alpha.53
  • v1.0.0-alpha.54
  • v1.0.0-alpha.55
  • v1.0.0-alpha.56
  • v1.0.0-alpha.57
  • v1.0.0-alpha.58
  • v1.0.0-alpha.59
  • v1.0.0-alpha.6
  • v1.0.0-alpha.60
  • v1.0.0-alpha.61
  • v1.0.0-alpha.62
  • v1.0.0-alpha.63
  • v1.0.0-alpha.64
  • v1.0.0-alpha.65
  • v1.0.0-alpha.66
  • v1.0.0-alpha.67
  • v1.0.0-alpha.68
  • v1.0.0-alpha.69
  • v1.0.0-alpha.7
  • v1.0.0-alpha.70
  • v1.0.0-alpha.71
  • v1.0.0-alpha.72
  • v1.0.0-alpha.73
  • v1.0.0-alpha.74
  • v1.0.0-alpha.75
  • v1.0.0-alpha.76
  • v1.0.0-alpha.77
  • v1.0.0-alpha.78
  • v1.0.0-alpha.79
  • v1.0.0-alpha.8
  • v1.0.0-alpha.80
  • v1.0.0-alpha.9
  • v1.0.0-beta.1
  • v1.0.0-beta.10
  • v1.0.0-beta.11
  • v1.0.0-beta.12
  • v1.0.0-beta.13
  • v1.0.0-beta.14
  • v1.0.0-beta.15
  • v1.0.0-beta.16
  • v1.0.0-beta.17
  • v1.0.0-beta.18
  • v1.0.0-beta.19
  • v1.0.0-beta.2
  • v1.0.0-beta.20
  • v1.0.0-beta.21
  • v1.0.0-beta.22
  • v1.0.0-beta.23
  • v1.0.0-beta.24
  • v1.0.0-beta.3
  • v1.0.0-beta.4
118 results
Show changes
Showing with 375 additions and 6 deletions
# [1.0.0-alpha.22](https://code.podlibre.org/podlibre/castopod/compare/v1.0.0-alpha.21...v1.0.0-alpha.22) (2020-11-24)
### Features
* **rss:** add transcript and chapters support ([e769d83](https://code.podlibre.org/podlibre/castopod/commit/e769d83a932c169e52a630a17cd4dd8ac5cebaf6)), closes [#72](https://code.podlibre.org/podlibre/castopod/issues/72) [#82](https://code.podlibre.org/podlibre/castopod/issues/82)
# [1.0.0-alpha.21](https://code.podlibre.org/podlibre/castopod/compare/v1.0.0-alpha.20...v1.0.0-alpha.21) (2020-11-24)
......
......@@ -7,7 +7,7 @@
//
// NOTE: this constant is updated upon release with Continuous Integration.
//
defined('CP_VERSION') || define('CP_VERSION', '1.0.0-alpha.21');
defined('CP_VERSION') || define('CP_VERSION', '1.0.0-alpha.22');
//--------------------------------------------------------------------
// App Namespace
......
......@@ -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'],
];
......
......@@ -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',
]
);
});
});
......
......@@ -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);
......
......@@ -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'],
......
......@@ -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(
......
......@@ -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);
}
......
......@@ -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',
],
......
......@@ -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',
],
......
......@@ -28,6 +28,8 @@ class EpisodeModel extends Model
'description_markdown',
'description_html',
'image_uri',
'transcript_uri',
'chapters_uri',
'parental_advisory',
'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 @@
<?= 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,
......
......@@ -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(
......
{
"name": "podlibre/castopod",
"version": "1.0.0-alpha21",
"version": "1.0.0-alpha22",
"type": "project",
"description": "Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience.",
"homepage": "https://castopod.org",
......
{
"name": "castopod",
"version": "1.0.0-alpha.21",
"version": "1.0.0-alpha.22",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
......
{
"name": "castopod",
"version": "1.0.0-alpha.21",
"version": "1.0.0-alpha.22",
"description": "Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience.",
"private": true,
"license": "AGPL-3.0-or-later",
......