Commit 9a5d5a15 authored by Benjamin Bellamy's avatar Benjamin Bellamy 💬 Committed by Yassine Doghri
Browse files

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
parent 9c224a8a
......@@ -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))
......@@ -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
......
......@@ -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');
......
......@@ -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');
......
......@@ -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');
......
......@@ -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',
......
......@@ -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',
......
......@@ -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/',
......
......@@ -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()
......
......@@ -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');
......
......@@ -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
*
......
......@@ -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 = [