feat: import podcast from an rss feed url

* add podcast import form
* add League\\HTMLToMarkdown
* add guid field in podcast table
* change podcast category from string to id

closes #21
This commit is contained in:
Benjamin Bellamy 2020-08-21 08:41:09 +00:00 committed by Yassine Doghri
parent 9c224a8ac6
commit 9a5d5a15b4
23 changed files with 782 additions and 71 deletions

View File

@ -5,13 +5,13 @@ Castopod uses the following components:
PHP Dependencies:
- [Code Igniter 4](https://codeigniter.com) ([MIT License](https://codeigniter.com/user_guide/license.html))
- [User agent list](https://github.com/opawg/user-agents) ([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE))
- [WhichBrowser/Parser-PHP](https://github.com/WhichBrowser/Parser-PHP) ([MIT License](https://github.com/WhichBrowser/Parser-PHP/blob/master/LICENSE))
- [GeoIP2 PHP API](https://github.com/maxmind/GeoIP2-php) ([Apache License 2.0](https://github.com/maxmind/GeoIP2-php/blob/master/LICENSE))
- [getID3](https://github.com/JamesHeinrich/getID3) ([GNU General Public License v3](https://github.com/JamesHeinrich/getID3/blob/2.0/licenses/license.gpl-30.txt))
- [myth-auth](https://github.com/lonnieezell/myth-auth) ([MIT license](https://github.com/lonnieezell/myth-auth/blob/develop/LICENSE.md))
- [commonmark](https://commonmark.thephpleague.com/) ([BSD 3-Clause "New" or "Revised" License](https://github.com/thephpleague/commonmark/blob/latest/LICENSE))
- [phpdotenv](https://github.com/vlucas/phpdotenv) ([ BSD-3-Clause License ](https://github.com/vlucas/phpdotenv/blob/master/LICENSE))
- [HTML To Markdown for PHP](https://github.com/thephpleague/html-to-markdown) ([MIT License](https://github.com/thephpleague/html-to-markdown/blob/master/LICENSE))
Javascript dependencies:
@ -24,3 +24,4 @@ Javascript dependencies:
Other:
- [RemixIcon](https://remixicon.com/) ([Apache License 2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License))
- [User agent list](https://github.com/opawg/user-agents) ([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE))

View File

@ -71,6 +71,10 @@ $routes->group(
'as' => 'admin',
]);
$routes->get('my-podcasts', 'Podcast::myPodcasts', [
'as' => 'my-podcasts',
]);
// Podcasts
$routes->group('podcasts', function ($routes) {
$routes->get('/', 'Podcast::list', [
@ -83,6 +87,13 @@ $routes->group(
$routes->post('new', 'Podcast::attemptCreate', [
'filter' => 'permission:podcasts-create',
]);
$routes->get('import', 'Podcast::import', [
'as' => 'podcast-import',
'filter' => 'permission:podcasts-import',
]);
$routes->post('import', 'Podcast::attemptImport', [
'filter' => 'permission:podcasts-import',
]);
// Podcast
// Use ids in admin area to help permission and group lookups

View File

@ -11,7 +11,9 @@ namespace App\Controllers\Admin;
use App\Models\CategoryModel;
use App\Models\LanguageModel;
use App\Models\PodcastModel;
use App\Models\EpisodeModel;
use Config\Services;
use League\HTMLToMarkdown\HtmlConverter;
class Podcast extends BaseController
{
@ -69,7 +71,7 @@ class Podcast extends BaseController
$categoryOptions = array_reduce(
$categories,
function ($result, $category) {
$result[$category->code] = lang(
$result[$category->id] = lang(
'Podcast.category_options.' . $category->code
);
return $result;
@ -110,7 +112,7 @@ class Podcast extends BaseController
),
'image' => $this->request->getFile('image'),
'language' => $this->request->getPost('language'),
'category' => $this->request->getPost('category'),
'category_id' => $this->request->getPost('category'),
'explicit' => $this->request->getPost('explicit') == 'yes',
'author' => $this->request->getPost('author'),
'owner_name' => $this->request->getPost('owner_name'),
@ -151,6 +153,222 @@ class Podcast extends BaseController
return redirect()->route('podcast-view', [$newPodcastId]);
}
public function import()
{
helper(['form', 'misc']);
$categories = (new CategoryModel())->findAll();
$languages = (new LanguageModel())->findAll();
$languageOptions = array_reduce(
$languages,
function ($result, $language) {
$result[$language->code] = $language->native_name;
return $result;
},
[]
);
$categoryOptions = array_reduce(
$categories,
function ($result, $category) {
$result[$category->id] = lang(
'Podcast.category_options.' . $category->code
);
return $result;
},
[]
);
$data = [
'languageOptions' => $languageOptions,
'categoryOptions' => $categoryOptions,
'browserLang' => get_browser_language(
$this->request->getServer('HTTP_ACCEPT_LANGUAGE')
),
];
return view('admin/podcast/import', $data);
}
public function attemptImport()
{
helper(['media', 'misc']);
$rules = [
'name' => 'required',
'imported_feed_url' => 'required',
];
if (!$this->validate($rules)) {
return redirect()
->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
try {
$feed = simplexml_load_file(
$this->request->getPost('imported_feed_url')
);
} catch (\ErrorException $ex) {
return redirect()
->back()
->withInput()
->with('errors', [
$ex->getMessage() .
': <a href="' .
$this->request->getPost('imported_feed_url') .
'" rel="noreferrer noopener" target="_blank">' .
$this->request->getPost('imported_feed_url') .
' ⎋</a>',
]);
}
$nsItunes = $feed->channel[0]->children(
'http://www.itunes.com/dtds/podcast-1.0.dtd'
);
$podcast = new \App\Entities\Podcast([
'name' => $this->request->getPost('name'),
'imported_feed_url' => $this->request->getPost('imported_feed_url'),
'title' => $feed->channel[0]->title,
'description' => $feed->channel[0]->description,
'image' => download_file($nsItunes->image->attributes()),
'language' => $this->request->getPost('language'),
'category_id' => $this->request->getPost('category'),
'explicit' => empty($nsItunes->explicit)
? false
: $nsItunes->explicit == 'yes',
'author' => $nsItunes->author,
'owner_name' => $nsItunes->owner->name,
'owner_email' => $nsItunes->owner->email,
'type' => empty($nsItunes->type) ? 'episodic' : $nsItunes->type,
'copyright' => $feed->channel[0]->copyright,
'block' => empty($nsItunes->block)
? false
: $nsItunes->block == 'yes',
'complete' => empty($nsItunes->complete)
? false
: $nsItunes->complete == 'yes',
'episode_description_footer' => '',
'custom_html_head' => '',
'created_by' => user(),
'updated_by' => user(),
]);
$podcastModel = new PodcastModel();
$db = \Config\Database::connect();
$db->transStart();
if (!($newPodcastId = $podcastModel->insert($podcast, true))) {
$db->transComplete();
return redirect()
->back()
->withInput()
->with('errors', $podcastModel->errors());
}
$authorize = Services::authorization();
$podcastAdminGroup = $authorize->group('podcast_admin');
$podcastModel->addPodcastContributor(
user()->id,
$newPodcastId,
$podcastAdminGroup->id
);
$converter = new HtmlConverter();
$numberItems = $feed->channel[0]->item->count();
$lastItem =
!empty($this->request->getPost('max_episodes')) &&
$this->request->getPost('max_episodes') < $numberItems
? $this->request->getPost('max_episodes')
: $numberItems;
$slugs = [];
// For each Episode:
for ($itemNumber = 1; $itemNumber <= $lastItem; $itemNumber++) {
$item = $feed->channel[0]->item[$numberItems - $itemNumber];
$nsItunes = $item->children(
'http://www.itunes.com/dtds/podcast-1.0.dtd'
);
$slug = slugify(
$this->request->getPost('slug_field') == 'title'
? $item->title
: basename($item->link)
);
if (in_array($slug, $slugs)) {
$slugNumber = 2;
while (in_array($slug . '-' . $slugNumber, $slugs)) {
$slugNumber++;
}
$slug = $slug . '-' . $slugNumber;
}
$slugs[] = $slug;
$newEpisode = new \App\Entities\Episode([
'podcast_id' => $newPodcastId,
'guid' => empty($item->guid) ? null : $item->guid,
'title' => $item->title,
'slug' => $slug,
'enclosure' => download_file($item->enclosure->attributes()),
'description' => $converter->convert(
$this->request->getPost('description_field') == 'summary'
? $nsItunes->summary
: ($this->request->getPost('description_field') ==
'subtitle_summary'
? '<h3>' .
$nsItunes->subtitle .
"</h3>\n" .
$nsItunes->summary
: $item->description)
),
'image' => empty($nsItunes->image->attributes())
? null
: download_file($nsItunes->image->attributes()),
'explicit' => $nsItunes->explicit == 'yes',
'number' => $this->request->getPost('force_renumber')
? $itemNumber
: $nsItunes->episode,
'season_number' => empty(
$this->request->getPost('season_number')
)
? $nsItunes->season
: $this->request->getPost('season_number'),
'type' => empty($nsItunes->episodeType)
? 'full'
: $nsItunes->episodeType,
'block' => empty($nsItunes->block)
? false
: $nsItunes->block == 'yes',
'created_by' => user(),
'updated_by' => user(),
]);
$newEpisode->setPublishedAt(
date('Y-m-d', strtotime($item->pubDate)),
date('H:i:s', strtotime($item->pubDate))
);
$episodeModel = new EpisodeModel();
if (!$episodeModel->save($newEpisode)) {
// FIX: What shall we do?
return redirect()
->back()
->withInput()
->with('errors', $episodeModel->errors());
}
}
$db->transComplete();
return redirect()->route('podcast-list');
}
public function edit()
{
helper('form');
@ -168,7 +386,7 @@ class Podcast extends BaseController
$categoryOptions = array_reduce(
$categories,
function ($result, $category) {
$result[$category->code] = lang(
$result[$category->id] = lang(
'Podcast.category_options.' . $category->code
);
return $result;
@ -212,7 +430,7 @@ class Podcast extends BaseController
$this->podcast->image = $image;
}
$this->podcast->language = $this->request->getPost('language');
$this->podcast->category = $this->request->getPost('category');
$this->podcast->category_id = $this->request->getPost('category');
$this->podcast->explicit = $this->request->getPost('explicit') == 'yes';
$this->podcast->author = $this->request->getPost('author');
$this->podcast->owner_name = $this->request->getPost('owner_name');

View File

@ -44,10 +44,11 @@ class AddPodcasts extends Migration
'type' => 'VARCHAR',
'constraint' => 2,
],
'category' => [
'type' => 'VARCHAR',
'constraint' => 1024,
'null' => true,
'category_id' => [
'type' => 'INT',
'constraint' => 10,
'unsigned' => true,
'default' => 0,
],
'explicit' => [
'type' => 'TINYINT',
@ -105,6 +106,13 @@ class AddPodcasts extends Migration
'constraint' => 11,
'unsigned' => true,
],
'imported_feed_url' => [
'type' => 'VARCHAR',
'constraint' => 1024,
'comment' =>
'The RSS feed URL if this podcast was imported, NULL otherwise.',
'null' => true,
],
'created_at' => [
'type' => 'TIMESTAMP',
],
@ -117,6 +125,7 @@ class AddPodcasts extends Migration
],
]);
$this->forge->addKey('id', true);
$this->forge->addForeignKey('category_id', 'categories', 'id');
$this->forge->addForeignKey('created_by', 'users', 'id');
$this->forge->addForeignKey('updated_by', 'users', 'id');
$this->forge->createTable('podcasts');

View File

@ -29,6 +29,10 @@ class AddEpisodes extends Migration
'constraint' => 20,
'unsigned' => true,
],
'guid' => [
'type' => 'VARCHAR',
'constraint' => 191,
],
'title' => [
'type' => 'VARCHAR',
'constraint' => 1024,
@ -59,16 +63,17 @@ class AddEpisodes extends Migration
'type' => 'INT',
'constraint' => 10,
'unsigned' => true,
'null' => true,
],
'season_number' => [
'type' => 'INT',
'constraint' => 10,
'unsigned' => true,
'default' => 1,
'null' => true,
],
'type' => [
'type' => 'ENUM',
'constraint' => ['full', 'trailer', 'bonus'],
'constraint' => ['trailer', 'full', 'bonus'],
'default' => 'full',
],
'block' => [
@ -103,8 +108,6 @@ class AddEpisodes extends Migration
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['podcast_id', 'slug']);
$this->forge->addUniqueKey(['podcast_id', 'season_number', 'number']);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->addForeignKey('created_by', 'users', 'id');
$this->forge->addForeignKey('updated_by', 'users', 'id');

View File

@ -66,6 +66,8 @@ class AddPlatforms extends Migration
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
'comment' =>
'Android deeplinking for this platform: 0=No, 1=Manual, 2=Automatic.',
],
'logo_file_name' => [
'type' => 'VARCHAR',

View File

@ -90,6 +90,11 @@ class AuthSeeder extends Seeder
'description' => 'Add a new podcast',
'has_permission' => ['superadmin'],
],
[
'name' => 'import',
'description' => 'Import a new podcast from an external feed',
'has_permission' => ['superadmin'],
],
[
'name' => 'list',
'description' => 'List all podcasts and their episodes',

View File

@ -209,6 +209,19 @@ class PlatformSeeder extends Seeder
'android_deeplink' => 2,
'logo_file_name' => 'Podbean.png',
],
[
'name' => 'Podcast Addict',
'home_url' => 'https://podcastaddict.com/',
'submit_url' => 'https://podcastaddict.com/submit',
'iosapp_url' => '',
'androidapp_url' =>
'https://play.google.com/store/apps/details?id=com.bambuna.podcastaddict',
'comment' => '',
'display_by_default' => 0,
'ios_deeplink' => 0,
'android_deeplink' => 2,
'logo_file_name' => 'podcastaddict.svg',
],
[
'name' => 'Podcastland',
'home_url' => 'https://podcastland.com/',

View File

@ -19,11 +19,6 @@ class Episode extends Entity
*/
protected $podcast;
/**
* @var string
*/
protected $GUID;
/**
* @var string
*/
@ -77,13 +72,14 @@ class Episode extends Entity
];
protected $casts = [
'guid' => 'string',
'slug' => 'string',
'title' => 'string',
'enclosure_uri' => 'string',
'description' => 'string',
'image_uri' => '?string',
'explicit' => 'boolean',
'number' => 'integer',
'number' => '?integer',
'season_number' => '?integer',
'type' => 'string',
'block' => 'boolean',
@ -91,9 +87,19 @@ class Episode extends Entity
'updated_by' => 'integer',
];
public function setImage(?\CodeIgniter\HTTP\Files\UploadedFile $image)
/**
* Saves an episode image
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $image
*
*/
public function setImage($image)
{
if (!empty($image) && $image->isValid()) {
if (
!empty($image) &&
(!($image instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$image->isValid())
) {
// check whether the user has inputted an image and store it
$this->attributes['image_uri'] = save_podcast_media(
$image,
@ -136,10 +142,19 @@ class Episode extends Entity
return $this->getPodcast()->image_url;
}
public function setEnclosure(
\CodeIgniter\HTTP\Files\UploadedFile $enclosure = null
) {
if (!empty($enclosure) && $enclosure->isValid()) {
/**
* Saves an enclosure
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $enclosure
*
*/
public function setEnclosure($enclosure = null)
{
if (
!empty($enclosure) &&
(!($enclosure instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$enclosure->isValid())
) {
helper('media');
$this->attributes['enclosure_uri'] = save_podcast_media(
@ -194,9 +209,11 @@ class Episode extends Entity
);
}
public function getGUID()
public function setGuid($guid = null)
{
return $this->getLink();
return $this->attributes['guid'] = empty($guid)
? $this->getLink()
: $guid;
}
public function getPodcast()

View File

@ -57,7 +57,7 @@ class Podcast extends Entity
'description' => 'string',
'image_uri' => 'string',
'language' => 'string',
'category' => 'string',
'category_id' => 'integer',
'explicit' => 'boolean',
'author' => '?string',
'owner_name' => '?string',
@ -70,9 +70,16 @@ class Podcast extends Entity
'custom_html_head' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
'imported_feed_url' => '?string',
];
public function setImage(\CodeIgniter\HTTP\Files\UploadedFile $image = null)
/**
* Saves a cover image to the corresponding podcast folder in `public/media/podcast_name/`
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $image
*
*/
public function setImage($image = null)
{
if ($image) {
helper('media');

View File

@ -9,7 +9,7 @@
/**
* Saves a file to the corresponding podcast folder in `public/media`
*
* @param \CodeIgniter\HTTP\Files\UploadedFile $file
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $file
* @param string $podcast_name
* @param string $file_name
*
@ -17,7 +17,12 @@
*/
function save_podcast_media($file, $podcast_name, $media_name)
{
$file_name = $media_name . '.' . $file->guessExtension();
$file_name = $media_name . '.' . $file->getExtension();
if (!file_exists(config('App')->mediaRoot . '/' . $podcast_name)) {
mkdir(config('App')->mediaRoot . '/' . $podcast_name, 0777, true);
touch(config('App')->mediaRoot . '/' . $podcast_name . '/index.html');
}
// move to media folder and overwrite file if already existing
$file->move(
@ -29,6 +34,20 @@ function save_podcast_media($file, $podcast_name, $media_name)
return $podcast_name . '/' . $file_name;
}
function download_file($fileUrl)
{
$tmpFilename =
time() .
'_' .
bin2hex(random_bytes(10)) .
'.' .
pathinfo($fileUrl, PATHINFO_EXTENSION);
$tmpFilePath = WRITEPATH . 'uploads/' . $tmpFilename;
file_put_contents($tmpFilePath, file_get_contents($fileUrl));
return new \CodeIgniter\Files\File($tmpFilePath);
}
/**
* Prefixes the root media path to a given uri
*

View File

@ -35,3 +35,111 @@ function startsWith($string, $query)
{
return substr($string, 0, strlen($query)) === $query;
}
function slugify($text)
{
if (empty($text)) {
return 'n-a';
}
// replace non letter or digits by -
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
$unwanted_array = [
'Š' => 'S',
'š' => 's',
'Đ' => 'Dj',
'đ' => 'dj',
'Ž' => 'Z',
'ž' => 'z',
'Č' => 'C',
'č' => 'c',
'Ć' => 'C',
'ć' => 'c',
'À' => 'A',
'Á' => 'A',
'Â' => 'A',
'Ã' => 'A',
'Ä' => 'A',
'Å' => 'A',
'Æ' => 'AE',
'Ç' => 'C',
'È' => 'E',
'É' => 'E',
'Ê' => 'E',
'Ë' => 'E',
'Ì' => 'I',
'Í' => 'I',
'Î' => 'I',
'Ï' => 'I',
'Ñ' => 'N',
'Ò' => 'O',
'Ó' => 'O',
'Ô' => 'O',
'Õ' => 'O',
'Ö' => 'O',
'Ø' => 'O',
'Œ' => 'OE',
'Ù' => 'U',
'Ú' => 'U',
'Û' => 'U',
'Ü' => 'U',
'Ý' => 'Y',
'Þ' => 'B',
'ß' => 'Ss',
'à' => 'a',
'á' => 'a',
'â' => 'a',
'ã' => 'a',
'ä' => 'a',
'å' => 'a',
'æ' => 'ae',
'ç' => 'c',
'è' => 'e',
'é' => 'e',
'ê' => 'e',
'ë' => 'e',
'ì' => 'i',
'í' => 'i',
'î' => 'i',
'ï' => 'i',
'ð' => 'o',
'ñ' => 'n',
'ò' => 'o',
'ó' => 'o',
'ô' => 'o',
'õ' => 'o',
'ö' => 'o',
'ø' => 'o',
'œ' => 'OE',
'ù' => 'u',
'ú' => 'u',
'û' => 'u',
'ý' => 'y',
'ý' => 'y',
'þ' => 'b',
'ÿ' => 'y',
'Ŕ' => 'R',
'ŕ' => 'r',
'/' => '-',
' ' => '-',
];
$text = strtr($text, $unwanted_array);
// transliterate
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
// remove unwanted characters
$text = preg_replace('~[^-\w]+~', '', $text);
// trim
$text = trim($text, '-');
// remove duplicate -
$text = preg_replace('~-+~', '-', $text);
// lowercase
$text = strtolower($text);
return $text;
}

View File

@ -23,7 +23,7 @@ function get_rss_feed($podcast)
$episodes = $podcast->episodes;
$podcast_category = $category_model
->where('code', $podcast->category)
->where('id', $podcast->category_id)
->first();
$itunes_namespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
@ -50,7 +50,7 @@ function get_rss_feed($podcast)
);
$channel->addChild(
'generator',
'Castopod 0.0.0-development - https://castopod.org'
'Castopod 0.0.0-development - https://castopod.org/'
);
$channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html');

View File

@ -19,4 +19,5 @@ return [
'users' => 'users',
'my-account' => 'my account',
'change-password' => 'change password',
'import' => 'feed import',
];

View File

@ -10,6 +10,7 @@ return [
'all_podcasts' => 'All podcasts',
'no_podcast' => 'No podcast found!',
'create' => 'Create a Podcast',
'import' => 'Create and Import a Podcast from an existing Feed',
'new_episode' => 'New Episode',
'feed' => 'RSS feed',
'view' => 'View podcast',
@ -21,10 +22,10 @@ return [
'form' => [
'title' => 'Title',
'title_help' =>
'This podcast title. It will be shown on all podcasts platforms (such as Apple Podcasts) and players (such as Podcast Addict).',
'The podcast title will be shown on all podcasts platforms (such as Apple Podcasts) and players (such as Podcast Addict).',
'name' => 'Name',
'name_help' =>
'This podcast name. It will be used in the URL address. It will be used as a Fedivers actor name, (for instance, it will be the podcast Mastodons name).',
'The podcast will be used in the URL address. It will be used as a Fediverse actor name, (for instance, it will be the podcast Mastodons name).',
'description' => 'Description',
'description_help' =>
'It will be shown on all podcasts platforms (such as Apple Podcasts) and players (such as Podcast Addict).',
@ -33,7 +34,7 @@ return [
'This text will be automatically added at the end of each episode description, so that you dont have to copy/paste it a gazillion times.',
'image' => 'Image',
'image_help' =>
'This podcast image. It should be square, JPEG or PNG, minimum 1400 x 1400 pixels and maximum 3000 x 3000 pixels.',
'This podcast image. It must be square, JPEG or PNG, minimum 1400 x 1400 pixels and maximum 3000 x 3000 pixels.',
'language' => 'Language',
'language_help' => 'The language spoken on the podcast.',
'category' => 'Category',
@ -44,10 +45,10 @@ return [
'The podcast parental advisory information. Does it contain explicit content?',
'owner_name' => 'Owner name',
'owner_name_help' =>
'The podcast owner contact name. For administrative use only. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.',
'For administrative use only. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.',
'owner_email' => 'Owner email',
'owner_email_help' =>
'The podcast owner contact e-mail. For administrative use only. It will mostly be used by some platforms to verify this podcast ownerhip. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.',
'It will be used by most platforms to verify this podcast ownership. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.',
'author' => 'Author',
'author_help' =>
'The group responsible for creating the show. Show author most often refers to the parent company or network of a podcast. This field is sometimes labeled as Author.',
@ -75,6 +76,36 @@ return [
'submit_create' => 'Create podcast',
'submit_edit' => 'Save podcast',
],
'form_import' => [
'name' => 'Name',
'name_help' =>
'This podcast name. It will be used in the URL address. It will be used as a Fediverse actor name, (for instance, it will be the podcast Mastodons name).',
'imported_feed_url' => 'Feed URL',
'imported_feed_url_help' =>
'Make sure you are legally allowed to copy that podcast.',
'force_renumber' => 'Force episodes renumbering',
'force_renumber_help' =>
'Use this if your old podcast does not have number but you want some on your new one.',
'season_number' => 'Season number',
'season_number_help' =>
'Use this if your old podcast does not have season number but you want one on your new one. Leave blank otherwise.',
'slug_field' => [
'label' => 'Which field should be used to calculate episode slug',
'link' => '&lt;link&gt;',
'title' => '&lt;title&gt;',
],
'description_field' => [
'label' => 'Source field used for episode description / show notes',
'description' => '&lt;description&gt;',
'summary' => '&lt;itunes:summary&gt;',
'subtitle_summary' =>
'&lt;itunes:subtitle&gt; &lt;itunes:summary&gt;',
],
'max_episodes' => 'Maximum number of episodes to import',
'max_episodes_helper' => 'Leave blank to import all episodes',
'submit_import' => 'Import podcast',
'submit_importing' => 'Importing podcast, this could take a while…',
],
'category_options' => [
'uncategorized' => 'uncategorized',
'arts' => 'Arts',

View File

@ -17,6 +17,7 @@ class EpisodeModel extends Model
protected $allowedFields = [
'podcast_id',
'guid',
'title',
'slug',
'enclosure_uri',
@ -44,8 +45,8 @@ class EpisodeModel extends Model
'enclosure_uri' => 'required',
'description' => 'required',
'image_uri' => 'required',
'number' => 'required|is_natural_no_zero',
'season_number' => 'required|is_natural_no_zero',
'number' => 'is_natural_no_zero|permit_empty',
'season_number' => 'is_natural_no_zero|permit_empty',
'type' => 'required',
'published_at' => 'valid_date|permit_empty',
'created_by' => 'required',

View File

@ -23,7 +23,7 @@ class PodcastModel extends Model
'episode_description_footer',
'image_uri',
'language',
'category',
'category_id',
'explicit',
'owner_name',
'owner_email',
@ -35,6 +35,7 @@ class PodcastModel extends Model
'custom_html_head',
'created_by',
'updated_by',
'imported_feed_url',
];
protected $returnType = \App\Entities\Podcast::class;
@ -49,8 +50,7 @@ class PodcastModel extends Model
'description' => 'required',
'image_uri' => 'required',
'language' => 'required',
'category' => 'required',
'owner_name' => 'required',
'category_id' => 'required',
'owner_email' => 'required|valid_email',
'type' => 'required',
'created_by' => 'required',

View File

@ -92,7 +92,7 @@
<?= form_dropdown(
'category',
$categoryOptions,
old('category', $podcast->category),
old('category', $podcast->category_id),
[
'id' => 'category',
'class' => 'form-select mb-4',

View File

@ -0,0 +1,163 @@
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Podcast.import') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= form_open_multipart(route_to('podcast_import'), [
'method' => 'post',
'class' => 'flex flex-col max-w-md',
]) ?>
<?= csrf_field() ?>
<div class="flex flex-col mb-4">
<label for="name"><?= lang('Podcast.form_import.name') ?></label>
<input type="text" class="form-input" id="name" name="name" value="<?= old(
'name'
) ?>" required />
</div>
<div class="flex flex-col mb-4">
<label for="name"><?= lang(
'Podcast.form_import.imported_feed_url'
) ?></label>
<input type="text" class="form-input" id="imported_feed_url" name="imported_feed_url" value="<?= old(
'imported_feed_url'
) ?>" required />
</div>
<?= form_label(lang('Podcast.form.language'), 'language') ?>
<?= form_dropdown('language', $languageOptions, old('language', $browserLang), [
'id' => 'language',
'class' => 'form-select mb-4',
'required' => 'required',
]) ?>
<?= form_label(lang('Podcast.form.category'), 'category') ?>
<?= form_dropdown('category', $categoryOptions, old('category'), [
'id' => 'category',
'class' => 'form-select mb-4',
'required' => 'required',
]) ?>
<?= form_fieldset(lang('Podcast.form_import.slug_field.label'), [
'class' => 'flex flex-col mb-4',
]) ?>
<label for="link" class="inline-flex items-center">
<?= form_radio(
['id' => 'link', 'name' => 'slug_field', 'class' => 'form-radio'],
'link',
old('slug_field') ? old('slug_field') == 'link' : true
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.slug_field.link'
) ?></span>
</label>
<label for="title" class="inline-flex items-center">
<?= form_radio(
['id' => 'title', 'name' => 'slug_field', 'class' => 'form-radio'],
'title',
old('slug_field') ? old('slug_field') == 'title' : false
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.slug_field.title'
) ?></span>
</label>
<?= form_fieldset_close() ?>
<?= form_fieldset(lang('Podcast.form_import.description_field.label'), [
'class' => 'flex flex-col mb-4',
]) ?>
<label for="description" class="inline-flex items-center">
<?= form_radio(
[
'id' => 'description',
'name' => 'description_field',
'class' => 'form-radio',
],
'description',
old('description_field')
? old('description_field') == 'description'
: true
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.description_field.description'
) ?></span>
</label>
<label for="subtitle_summary" class="inline-flex items-center">
<?= form_radio(
[
'id' => 'summary',
'name' => 'description_field',
'class' => 'form-radio',
],
'summary',
old('description_field')
? old('description_field') == 'summary'
: false
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.description_field.summary'
) ?></span>
</label>
<label for="subtitle_summary" class="inline-flex items-center">
<?= form_radio(
[
'id' => 'subtitle_summary',
'name' => 'description_field',
'class' => 'form-radio',
],
'subtitle_summary',
old('description_field')
? old('description_field') == 'subtitle_summary'
: false
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.description_field.subtitle_summary'
) ?></span>
</label>
<?= form_fieldset_close() ?>
<label class="inline-flex items-center mb-4">
<?= form_checkbox(
[
'id' => 'force_renumber',
'name' => 'force_renumber',
'class' => 'form-checkbox',
],
'yes',
old('force_renumber', false)
) ?>
<span class="ml-2"><?= lang('Podcast.form_import.force_renumber') ?></span>
</label>
<div class="flex flex-col mb-4">
<label for="name"><?= lang('Podcast.form_import.season_number') ?></label>
<input type="text" class="form-input" id="season_number" name="season_number" value="<?= old(
'season_number'
) ?>" />
</div>
<div class="flex flex-col mb-4">
<label for="max_episodes"><?= lang(
'Podcast.form_import.max_episodes'
) ?></label>
<input type="text" class="form-input" id="max_episodes" name="max_episodes" value="<?= old(
'max_episodes'
) ?>" />
</div>
<button type="submit" name="submit" onsubmit="this.disabled=true; this.value='<?= lang(
'Podcast.form_import.submit_importing'
) ?>';" class="self-end px-4 py-2 bg-gray-200"><?= lang(
'Podcast.form_import.submit_import'
) ?></button>
<?= form_close() ?>
<?= $this->endSection() ?>

View File

@ -7,6 +7,11 @@
) ?>">
<?= icon('add', 'mr-2') ?>
<?= lang('Podcast.create') ?></a>
<a class="inline-flex items-center px-2 py-1 mb-2 ml-4 text-sm text-white bg-green-500 rounded shadow-xs outline-none hover:bg-green-600 focus:shadow-outline" href="<?= route_to(
'podcast-import'
) ?>">
<?= icon('add', 'mr-2') ?>
<?= lang('Podcast.import') ?></a>
<?= $this->endSection() ?>

View File

@ -12,7 +12,8 @@
"myth/auth": "dev-develop",
"codeigniter4/codeigniter4": "dev-develop",
"league/commonmark": "^1.5",
"vlucas/phpdotenv": "^5.1"
"vlucas/phpdotenv": "^5.1",
"league/html-to-markdown": "^4.10"
},
"require-dev": {
"mikey179/vfsstream": "1.6.*",

136
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a6be291e1c7f73b73182cd7b49234688",
"content-hash": "38eeae7f5d0143863430cda9df10d487",
"packages": [
{
"name": "codeigniter4/codeigniter4",
@ -12,12 +12,12 @@
"source": {
"type": "git",
"url": "https://github.com/codeigniter4/CodeIgniter4.git",
"reference": "cbfc8d27645fc9fe19d540c796b627852b4a1142"
"reference": "9a7e826138bf8940ef8c7a25d59d67b1aebfe0ee"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/cbfc8d27645fc9fe19d540c796b627852b4a1142",
"reference": "cbfc8d27645fc9fe19d540c796b627852b4a1142",
"url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/9a7e826138bf8940ef8c7a25d59d67b1aebfe0ee",
"reference": "9a7e826138bf8940ef8c7a25d59d67b1aebfe0ee",
"shasum": ""
},
"require": {
@ -34,6 +34,7 @@
"codeigniter4/codeigniter4-standard": "^1.0",
"fzaninotto/faker": "^1.9@dev",
"mikey179/vfsstream": "1.6.*",
"phpstan/phpstan": "^0.12.37",
"phpunit/phpunit": "^8.5",
"predis/predis": "^1.1",
"squizlabs/php_codesniffer": "^3.3"
@ -65,7 +66,7 @@
"slack": "https://codeigniterchat.slack.com",
"issues": "https://github.com/codeigniter4/CodeIgniter4/issues"
},
"time": "2020-08-04T03:43:32+00:00"
"time": "2020-08-17T14:11:23+00:00"
},
{
"name": "composer/ca-bundle",
@ -602,30 +603,112 @@
"time": "2020-07-19T22:47:30+00:00"
},
{
"name": "maxmind-db/reader",
"version": "v1.6.0",
"name": "league/html-to-markdown",
"version": "4.10.0",
"source": {
"type": "git",
"url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git",
"reference": "febd4920bf17c1da84cef58e56a8227dfb37fbe4"
"url": "https://github.com/thephpleague/html-to-markdown.git",
"reference": "0868ae7a552e809e5cd8f93ba022071640408e88"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/febd4920bf17c1da84cef58e56a8227dfb37fbe4",
"reference": "febd4920bf17c1da84cef58e56a8227dfb37fbe4",
"url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0868ae7a552e809e5cd8f93ba022071640408e88",
"reference": "0868ae7a552e809e5cd8f93ba022071640408e88",
"shasum": ""
},
"require": {
"php": ">=5.6"
"ext-dom": "*",
"ext-xml": "*",
"php": ">=5.3.3"
},
"require-dev": {
"mikehaertl/php-shellcommand": "~1.1.0",
"phpunit/phpunit": "^4.8|^5.7",
"scrutinizer/ocular": "~1.1"
},
"bin": [
"bin/html-to-markdown"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.10-dev"
}
},
"autoload": {
"psr-4": {
"League\\HTMLToMarkdown\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
},
{
"name": "Nick Cernis",
"email": "nick@cern.is",
"homepage": "http://modernnerd.net",
"role": "Original Author"
}
],
"description": "An HTML-to-markdown conversion helper for PHP",
"homepage": "https://github.com/thephpleague/html-to-markdown",
"keywords": [
"html",
"markdown"
],
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://www.patreon.com/colinodell",
"type": "patreon"
}
],
"time": "2020-07-01T00:34:03+00:00"
},
{
"name": "maxmind-db/reader",
"version": "v1.7.0",
"source": {
"type": "git",
"url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git",
"reference": "942553da239f12051275f9c666538b5dd09e2908"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/942553da239f12051275f9c666538b5dd09e2908",
"reference": "942553da239f12051275f9c666538b5dd09e2908",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"conflict": {
"ext-maxminddb": "<1.6.0,>=2.0.0"
"ext-maxminddb": "<1.7.0,>=2.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "2.*",
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpcov": "^3.0",
"phpunit/phpunit": "5.*",
"phpunit/phpcov": ">=6.0.0",
"phpunit/phpunit": ">=8.0.0,<10.0.0",
"squizlabs/php_codesniffer": "3.*"
},
"suggest": {
@ -659,7 +742,7 @@
"geolocation",
"maxmind"
],
"time": "2019-12-19T22:59:03+00:00"
"time": "2020-08-07T22:10:05+00:00"
},
{
"name": "maxmind/web-service-common",
@ -1618,16 +1701,16 @@
},
{
"name": "phpdocumentor/reflection-docblock",
"version": "5.2.0",
"version": "5.2.1",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
"reference": "3170448f5769fe19f456173d833734e0ff1b84df"
"reference": "d870572532cd70bc3fab58f2e23ad423c8404c44"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/3170448f5769fe19f456173d833734e0ff1b84df",
"reference": "3170448f5769fe19f456173d833734e0ff1b84df",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/d870572532cd70bc3fab58f2e23ad423c8404c44",
"reference": "d870572532cd70bc3fab58f2e23ad423c8404c44",
"shasum": ""
},
"require": {
@ -1666,7 +1749,7 @@
}
],
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"time": "2020-07-20T20:05:34+00:00"
"time": "2020-08-15T11:14:08+00:00"
},
{
"name": "phpdocumentor/type-resolver",
@ -2026,6 +2109,7 @@
"keywords": [
"tokenizer"
],
"abandoned": true,
"time": "2019-09-17T06:23:10+00:00"
},
{
@ -2738,16 +2822,16 @@
},
{
"name": "squizlabs/php_codesniffer",
"version": "3.5.5",
"version": "3.5.6",
"source": {
"type": "git",
"url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
"reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6"
"reference": "e97627871a7eab2f70e59166072a6b767d5834e0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/73e2e7f57d958e7228fce50dc0c61f58f017f9f6",
"reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6",
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/e97627871a7eab2f70e59166072a6b767d5834e0",
"reference": "e97627871a7eab2f70e59166072a6b767d5834e0",
"shasum": ""
},
"require": {
@ -2785,7 +2869,7 @@
"phpcs",
"standards"
],
"time": "2020-04-17T01:09:41+00:00"
"time": "2020-08-10T04:50:15+00:00"
},
{
"name": "theseer/tokenizer",

View File

@ -106,12 +106,24 @@ docker-compose run --rm app php spark migrate -all
2. Populate the database with the required data:
```bash
# Populates all required data
docker-compose run --rm app php spark db:seed AppSeeder
```
You may also add only data you chose:
```bash
# Populates all categories
docker-compose run --rm app php spark db:seed CategorySeeder
# Populates all Languages
docker-compose run --rm app php spark db:seed LanguageSeeder
# Populates all podcasts platforms
docker-compose run --rm app php spark db:seed PlatformSeeder
# Populates all Authentication data (roles definition…)
docker-compose run --rm app php spark db:seed AuthSeeder
# Populates test data (login: admin / password: AGUehL3P)
docker-compose run --rm app php spark db:seed TestSeeder
```
3. (optionnal) Populate the database with test data: