Skip to content
Snippets Groups Projects
PodcastImport.php 21.4 KiB
Newer Older
<?php

declare(strict_types=1);

namespace Modules\PodcastImport\Commands;

use AdAures\PodcastPersonsTaxonomy\ReversedTaxonomy;
use App\Entities\Episode;
use App\Entities\Location;
use App\Entities\Person;
use App\Entities\Platform;
use App\Entities\Podcast;
use App\Models\EpisodeModel;
use App\Models\PersonModel;
use App\Models\PlatformModel;
use App\Models\PodcastModel;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\I18n\Time;
use CodeIgniter\Shield\Entities\User;
use Exception;
use League\HTMLToMarkdown\HtmlConverter;
use Modules\Auth\Models\UserModel;
use Modules\PodcastImport\Entities\PodcastImportTask;
use Modules\PodcastImport\Entities\TaskStatus;
use PodcastFeed\PodcastFeed;
use PodcastFeed\Tags\Podcast\PodcastPerson;
use PodcastFeed\Tags\RSS\Channel;
use PodcastFeed\Tags\RSS\Item;

class PodcastImport extends BaseCommand
{
    protected $group = 'podcast-import';

    protected $name = 'podcast:import';

    protected $description = 'Runs next podcast import in queue.';

    protected PodcastFeed $podcastFeed;

    protected User $user;

    protected ?PodcastImportTask $importTask = null;

    protected ?Podcast $podcast = null;

    public function init(): void
    {
        CLI::clearScreen();

        helper('podcast_import');

        $importQueue = get_import_tasks();

        $currentImport = current(array_filter($importQueue, static function ($task): bool {
            return $task->status === TaskStatus::Running;
        }));

        if ($currentImport instanceof PodcastImportTask) {
            $currentImport->syncWithProcess();

            if ($currentImport->status === TaskStatus::Running) {
                // process is still running
                throw new Exception('An import is already running.');
            }

            // continue if the task is not running anymore
        }

        // Get the next queued import
        $queuedImports = array_filter($importQueue, static function ($task): bool {
            return $task->status === TaskStatus::Queued;
        });
        $nextImport = end($queuedImports);

        if (! $nextImport instanceof PodcastImportTask) {
        }

        $this->importTask = $nextImport;

        // retrieve user who created import task
        $user = (new UserModel())->find($this->importTask->created_by);

        if (! $user instanceof User) {
            throw new Exception('Could not retrieve user with ID: ' . $this->importTask->created_by);
        }

        $this->user = $user;

        CLI::write('Fetching and parsing RSS feed...');

        ini_set('user_agent', 'Castopod/' . CP_VERSION);
        $this->podcastFeed = new PodcastFeed($this->importTask->feed_url);
    }

    public function run(array $params): void
    {
        try {
            $this->init();

            CLI::write('All good! Feed was parsed successfully!');

            CLI::write(
                'Starting import for @' . $this->importTask->handle . ' using feed: ' . $this->importTask->feed_url
            );

            // --- START IMPORT TASK ---
            $this->importTask->start();

            CLI::write('Checking if podcast is locked.');

            if ($this->podcastFeed->channel->podcast_locked->getValue()) {
                throw new Exception('🔒 Podcast is locked.');
            }

            CLI::write('Podcast is not locked, import can resume.');

            // check if podcast to be imported already exists by guid if exists or handle otherwise
            $podcastGuid = $this->podcastFeed->channel->podcast_guid->getValue();
            if ($podcastGuid !== null) {
                $podcast = (new PodcastModel())->where('guid', $podcastGuid)
                    ->first();
            } else {
                $podcast = (new PodcastModel())->where('handle', $this->importTask->handle)
                    ->first();
            }

            if ($podcast instanceof Podcast) {
                if ($podcast->handle !== $this->importTask->handle) {
                    throw new Exception('Podcast was already imported with a different handle.');
                }

                CLI::write('Podcast handle already exists, using existing one.');
                $this->podcast = $podcast;
            }

            helper(['media', 'misc', 'auth']);

            if (! $this->podcast instanceof Podcast) {
                $this->podcast = $this->importPodcast();
            }

            CLI::write('Adding podcast platforms...');

            $this->importPodcastPlatforms();

            CLI::write('Adding persons - ' . count($this->podcastFeed->channel->podcast_persons) . ' elements.');

            $this->importPodcastPersons();

            $this->importEpisodes();

            // set podcast publication date to the first ever published episode
            $this->podcast->published_at = $this->getOldestEpisodePublicationDate(
                $this->podcast->id
            ) ?? $this->podcast->created_at;

            $podcastModel = new PodcastModel();
            if (! $podcastModel->update($this->podcast->id, $this->podcast)) {
                throw new Exception(print_r($podcastModel->errors()));
            }

            CLI::showProgress(false);

            // // done, set status to passed
            $this->importTask->pass();
        } catch (Exception $exception) {
            $this->error($exception->getMessage());
        }
    }

    private function getOldestEpisodePublicationDate(int $podcastId): ?Time
    {
        $result = (new EpisodeModel())
            ->builder()
            ->selectMax('published_at', 'oldest_published_at')
            ->where('podcast_id', $podcastId)
            ->get()
            ->getResultArray();

        if ($result[0]['oldest_published_at'] === null) {
            return null;
        }

        return Time::createFromFormat('Y-m-d H:i:s', $result[0]['oldest_published_at']);
    }

    private function importPodcast(): Podcast
    {
        $db = db_connect();
        $db->transStart();

        $location = null;
        if ($this->podcastFeed->channel->podcast_location->getValue() !== null) {
            $location = new Location(
                $this->podcastFeed->channel->podcast_location->getValue(),
                $this->podcastFeed->channel->podcast_location->getAttribute('geo'),
                $this->podcastFeed->channel->podcast_location->getAttribute('osm'),
            );
        }

        if (($showNotes = $this->getShowNotes($this->podcastFeed->channel)) === null) {
            throw new Exception('Missing channel show notes. Please include a <description> tag.');
        }

        if (($coverUrl = $this->getCoverUrl($this->podcastFeed->channel)) === null) {
            throw new Exception('Missing podcast cover. Please include an <itunes:image> tag');
        }

        $htmlConverter = new HtmlConverter();
        $podcast = new Podcast([
            'created_by'           => $this->user->id,
            'updated_by'           => $this->user->id,
            'guid'                 => $this->podcastFeed->channel->podcast_guid->getValue(),
            'handle'               => $this->importTask->handle,
            'imported_feed_url'    => $this->importTask->feed_url,
            'new_feed_url'         => url_to('podcast-rss-feed', $this->importTask->handle),
            'title'                => $this->podcastFeed->channel->title->getValue(),
            'description_markdown' => $htmlConverter->convert($showNotes),
            'description_html'     => $showNotes,
            'cover'                => download_file($coverUrl),
            'banner'               => null,
            'language_code'        => $this->importTask->language,
            'category_id'          => $this->importTask->category,
            'parental_advisory'    => $this->podcastFeed->channel->itunes_explicit->getValue(),
            'owner_name'           => $this->podcastFeed->channel->itunes_owner->itunes_name->getValue(),
            'owner_email'          => $this->podcastFeed->channel->itunes_owner->itunes_email->getValue(),
            'publisher'            => $this->podcastFeed->channel->itunes_author->getValue(),
            'type'                 => $this->podcastFeed->channel->itunes_type->getValue(),
            'copyright'            => $this->podcastFeed->channel->copyright->getValue(),
            'is_blocked'           => $this->podcastFeed->channel->itunes_block->getValue(),
            'is_completed'         => $this->podcastFeed->channel->itunes_complete->getValue(),
            'location'             => $location,
        ]);

        $podcastModel = new PodcastModel();
        if (! ($podcastId = $podcastModel->insert($podcast, true))) {
            $db->transRollback();
            throw new Exception(print_r($podcastModel->errors()));
        }

        $podcast->id = $podcastId;

        // set current user as podcast admin
        // 1. create new group
        config('AuthGroups')
            ->generatePodcastAuthorizations($podcast->id);
        add_podcast_group($this->user, $podcast->id, 'admin');

        $db->transComplete(); // save podcast to database

        CLI::write('Podcast was successfully created!');

        return $podcast;
    }

    private function getShowNotes(Channel|Item $channelOrItem): ?string
    {
        if (! $channelOrItem instanceof Item) {
            return $channelOrItem->description->getValue() ?? $channelOrItem->itunes_summary->getValue();
        }

        if ($channelOrItem->content_encoded->getValue() !== null) {
            return $channelOrItem->content_encoded->getValue();
        }

        return $channelOrItem->description->getValue() ?? $channelOrItem->itunes_summary->getValue();
    }

    private function getCoverUrl(Channel|Item $channelOrItem): ?string
    {
        if ($channelOrItem->itunes_image->getAttribute('href') !== null) {
            return $channelOrItem->itunes_image->getAttribute('href');
        }

        if ($channelOrItem instanceof Channel && $channelOrItem->image->url->getValue() !== null) {
            return $channelOrItem->image->url->getValue();
        }

        return null;
    }

    private function importPodcastPersons(): void
    {
        $personsCount = count($this->podcastFeed->channel->podcast_persons);
        $currPersonsStep = 1; // for progress
        foreach ($this->podcastFeed->channel->podcast_persons as $person) {
            CLI::showProgress($currPersonsStep++, $personsCount);
            $fullName = $person->getValue();
            $newPersonId = null;
            $personModel = new PersonModel();
            if (($newPerson = $personModel->getPerson($fullName)) instanceof Person) {
                $newPersonId = $newPerson->id;
            } else {
                $newPodcastPerson = new Person([
                    'created_by'      => $this->user->id,
                    'updated_by'      => $this->user->id,
                    'full_name'       => $fullName,
                    'unique_name'     => slugify($fullName),
                    'information_url' => $person->getAttribute('href'),
                    'avatar'          => download_file((string) $person->getAttribute('img')),
                ]);

                if (! $newPersonId = $personModel->insert($newPodcastPerson)) {
                    throw new Exception((string) print_r($personModel->errors()));
                }
            }

            $personGroup = $person->getAttribute('group');
            $personRole = $person->getAttribute('role');

            $isTaxonomyFound = false;
            if (array_key_exists(strtolower((string) $personGroup), ReversedTaxonomy::$taxonomy)) {
                $personGroup = ReversedTaxonomy::$taxonomy[strtolower((string) $personGroup)];
                $personGroupSlug = $personGroup['slug'];

                if (array_key_exists(strtolower((string) $personRole), $personGroup['roles'])) {
                    $personRoleSlug = $personGroup['roles'][strtolower((string) $personRole)]['slug'];
                    $isTaxonomyFound = true;
                }
            }
            if (! $isTaxonomyFound) {
                // taxonomy was not found, set default group and role
                $personGroupSlug = 'cast';
                $personRoleSlug = 'host';
            }

            $podcastPersonModel = new PersonModel();
            if (! $podcastPersonModel->addPodcastPerson(
                $this->podcast->id,
                $newPersonId,
                $personGroupSlug,
                $personRoleSlug
            )) {
                throw new Exception(print_r($podcastPersonModel->errors()));
            }
        }

        CLI::showProgress(false);
    }

    private function importPodcastPlatforms(): void
    {
        $platformTypes = [
            [
                'name'            => 'podcasting',
                'elements'        => $this->podcastFeed->channel->podcast_ids,
                'count'           => count($this->podcastFeed->channel->podcast_ids),
                'account_url_key' => 'url',
                'account_id_key'  => 'id',
            ],
            [
                'name'            => 'social',
                'elements'        => $this->podcastFeed->channel->podcast_socials,
                'count'           => count($this->podcastFeed->channel->podcast_socials),
                'account_url_key' => 'accountUrl',
                'account_id_key'  => 'accountId',
            ],
            [
                'name'            => 'funding',
                'elements'        => $this->podcastFeed->channel->podcast_fundings,
                'count'           => count($this->podcastFeed->channel->podcast_fundings),
                'account_url_key' => 'url',
                'account_id_key'  => 'id',
            ],
        ];

        $platformModel = new PlatformModel();
        foreach ($platformTypes as $platformType) {
            $podcastsPlatformsData = [];
            $currPlatformStep = 1; // for progress
            CLI::write($platformType['name'] . ' - ' . $platformType['count'] . ' elements');
            foreach ($platformType['elements'] as $platform) {
                CLI::showProgress($currPlatformStep++, $platformType['count']);
                $platformLabel = $platform->getAttribute('platform');
                $platformSlug = slugify((string) $platformLabel);
                if ($platformModel->getPlatform($platformSlug) instanceof Platform) {
                    $podcastsPlatformsData[] = [
                        'platform_slug' => $platformSlug,
                        'podcast_id'    => $this->podcast->id,
                        'link_url'      => $platform->getAttribute($platformType['account_url_key']),
                        'account_id'    => $platform->getAttribute($platformType['account_id_key']),
                        'is_visible'    => false,
                    ];
                }
            }

            $platformModel->savePodcastPlatforms($this->podcast->id, $platformType['name'], $podcastsPlatformsData);
            CLI::showProgress(false);
        }
    }

    private function importEpisodes(): void
    {
        helper('text');

        $itemsCount = count($this->podcastFeed->channel->items);
        $this->importTask->setEpisodesCount($itemsCount);

        CLI::write('Adding episodes - ' . $itemsCount . ' episodes');

        $htmlConverter = new HtmlConverter();

        $importedGUIDs = $this->getImportedGUIDs($this->podcast->id);

        $currEpisodesStep = 0; // for progress
        $episodesNewlyImported = 0;
        $episodesAlreadyImported = 0;

        // insert episodes in reverse order, from the last item in the list to the first
        foreach (array_reverse($this->podcastFeed->channel->items) as $key => $item) {
            CLI::showProgress(++$currEpisodesStep, $itemsCount);

            if (in_array($item->guid->getValue(), $importedGUIDs, true)) {
                // do not import item if already imported
                // (check that item with guid has already been inserted)
                $this->importTask->setEpisodesAlreadyImported(++$episodesAlreadyImported);
                continue;
            }

            $db = db_connect();
            $db->transStart();

            $location = null;
            if ($item->podcast_location->getValue() !== null) {
                $location = new Location(
                    $item->podcast_location->getValue(),
                    $item->podcast_location->getAttribute('geo'),
                    $item->podcast_location->getAttribute('osm'),
                );
            }

            if (($showNotes = $this->getShowNotes($item)) === null) {
                throw new Exception('Missing item show notes. Please include a <description> tag to item ' . $key);
            }

            $coverUrl = $this->getCoverUrl($item);

            $episode = new Episode([
                'created_by' => $this->user->id,
                'updated_by' => $this->user->id,
                'podcast_id' => $this->podcast->id,
                'title'      => $item->title->getValue(),
                'slug'       => slugify((string) $item->title->getValue(), 120) . '-' . strtolower(
                    random_string('alnum', 5)
                ),
                'guid'  => $item->guid->getValue(),
                'audio' => download_file(
                    $item->enclosure->getAttribute('url'),
                    $item->enclosure->getAttribute('type')
                ),
                'description_markdown' => $htmlConverter->convert($showNotes),
                'description_html'     => $showNotes,
                'cover'                => $coverUrl ? download_file($coverUrl) : null,
                'parental_advisory'    => $item->itunes_explicit->getValue(),
                'number'               => $item->itunes_episode->getValue(),
                'season_number'        => $item->itunes_season->getValue(),
                'type'                 => $item->itunes_episodeType->getValue(),
                'is_blocked'           => $item->itunes_block->getValue(),
                'location'             => $location,
                'published_at'         => $item->pubDate->getValue(),
            ]);

            $episodeModel = new EpisodeModel();

            if (! ($episodeId = $episodeModel->insert($episode, true))) {
                $db->transRollback();
                throw new Exception(print_r($episodeModel->errors()));
            }

            $this->importEpisodePersons($episodeId, $item->podcast_persons);

            $this->importTask->setEpisodesNewlyImported(++$episodesNewlyImported);

            $db->transComplete();
        }
    }

    /**
     * @return string[]
     */
    private function getImportedGUIDs(int $podcastId): array
    {
        $result = (new EpisodeModel())
            ->builder()
            ->select('guid')
            ->where('podcast_id', $podcastId)
            ->get()
            ->getResultArray();

        return array_map(static function ($element) {
            return $element['guid'];
        }, $result);
    }

    /**
     * @param PodcastPerson[] $persons
     */
    private function importEpisodePersons(int $episodeId, array $persons): void
    {
        foreach ($persons as $person) {
            $fullName = $person->getValue();
            $personModel = new PersonModel();
            $newPersonId = null;
            if (($newPerson = $personModel->getPerson($fullName)) instanceof Person) {
                $newPersonId = $newPerson->id;
            } else {
                $newPerson = new Person([
                    'created_by'      => $this->user->id,
                    'updated_by'      => $this->user->id,
                    'full_name'       => $fullName,
                    'unique_name'     => slugify($fullName),
                    'information_url' => $person->getAttribute('href'),
                    'avatar'          => download_file((string) $person->getAttribute('img')),
                ]);

                if (! ($newPersonId = $personModel->insert($newPerson))) {
                    throw new Exception(print_r($personModel->errors()));
                }
            }

            $personGroup = $person->getAttribute('group');
            $personRole = $person->getAttribute('role');

            $isTaxonomyFound = false;
            if (array_key_exists(strtolower((string) $personGroup), ReversedTaxonomy::$taxonomy)) {
                $personGroup = ReversedTaxonomy::$taxonomy[strtolower((string) $personGroup)];
                $personGroupSlug = $personGroup['slug'];
                if (array_key_exists(strtolower((string) $personRole), $personGroup['roles'])) {
                    $personRoleSlug = $personGroup['roles'][strtolower((string) $personRole)]['slug'];
                    $isTaxonomyFound = true;
                }
            }

            if (! $isTaxonomyFound) {
                // taxonomy was not found, set default group and role
                $personGroupSlug = 'cast';
                $personRoleSlug = 'host';
            }

            $episodePersonModel = new PersonModel();
            if (! $episodePersonModel->addEpisodePerson(
                $this->podcast->id,
                $episodeId,
                $newPersonId,
                $personGroupSlug,
                $personRoleSlug
            )) {
                throw new Exception(print_r($episodePersonModel->errors()));
            }
        }
    }

    private function error(string $message): void
    {
        if ($this->importTask instanceof PodcastImportTask) {
            $this->importTask->fail($message);
        }

        CLI::error('[Error] ' . $message);
    }
}