From b144860d63a6c2591161d4480783194976a66d16 Mon Sep 17 00:00:00 2001 From: Buster Neece Date: Thu, 21 Mar 2024 09:27:52 -0500 Subject: [PATCH] #7009 -- Make RSS feed use API generator, add extension to download for iTunes. --- config/routes/api_public.php | 2 +- config/routes/public.php | 3 +- .../PublicPages/PodcastFeedAction.php | 398 +++++------------- src/Entity/Api/PodcastEpisode.php | 3 + .../PodcastEpisodeApiGenerator.php | 16 +- 5 files changed, 125 insertions(+), 297 deletions(-) diff --git a/config/routes/api_public.php b/config/routes/api_public.php index 0c9be8a44..5b1739240 100644 --- a/config/routes/api_public.php +++ b/config/routes/api_public.php @@ -118,7 +118,7 @@ return static function (RouteCollectorProxy $group) { ->add(new Middleware\Cache\SetStaticFileCache()); $group->get( - '/download', + '/download[.{extension}]', Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class )->setName('api:stations:public:podcast:episode:download'); } diff --git a/config/routes/public.php b/config/routes/public.php index aef1d85b9..a501bdd34 100644 --- a/config/routes/public.php +++ b/config/routes/public.php @@ -69,7 +69,8 @@ return static function (RouteCollectorProxy $app) { } $group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class) - ->setName('public:podcast:feed'); + ->setName('public:podcast:feed') + ->add(Middleware\GetAndRequirePodcast::class); } ) ->add(Middleware\EnableView::class) diff --git a/src/Controller/Frontend/PublicPages/PodcastFeedAction.php b/src/Controller/Frontend/PublicPages/PodcastFeedAction.php index 2bb39c582..af57b9387 100644 --- a/src/Controller/Frontend/PublicPages/PodcastFeedAction.php +++ b/src/Controller/Frontend/PublicPages/PodcastFeedAction.php @@ -5,19 +5,15 @@ declare(strict_types=1); namespace App\Controller\Frontend\PublicPages; use App\Controller\SingleActionInterface; +use App\Entity\ApiGenerator\PodcastApiGenerator; +use App\Entity\ApiGenerator\PodcastEpisodeApiGenerator; use App\Entity\Podcast; use App\Entity\PodcastCategory; use App\Entity\PodcastEpisode; -use App\Entity\Repository\PodcastRepository; -use App\Entity\Repository\StationRepository; -use App\Entity\Station; use App\Exception\NotFoundException; -use App\Flysystem\StationFilesystems; use App\Http\Response; -use App\Http\RouterInterface; use App\Http\ServerRequest; use DateTime; -use GuzzleHttp\Psr7\UriResolver; use MarcW\RssWriter\Extension\Atom\AtomLink; use MarcW\RssWriter\Extension\Atom\AtomWriter; use MarcW\RssWriter\Extension\Core\Category as RssCategory; @@ -40,12 +36,9 @@ use Psr\Http\Message\ResponseInterface; final class PodcastFeedAction implements SingleActionInterface { - private RouterInterface $router; - public function __construct( - private readonly StationRepository $stationRepository, - private readonly PodcastRepository $podcastRepository, - private readonly StationFilesystems $stationFilesystems + private readonly PodcastApiGenerator $podcastApiGenerator, + private readonly PodcastEpisodeApiGenerator $episodeApiGenerator ) { } @@ -54,112 +47,125 @@ final class PodcastFeedAction implements SingleActionInterface Response $response, array $params ): ResponseInterface { - /** @var string $podcastId */ - $podcastId = $params['podcast_id']; - - $this->router = $request->getRouter(); - $station = $request->getStation(); - if (!$station->getEnablePublicPage()) { throw NotFoundException::station(); } - $podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcastId); + $podcast = $request->getPodcast(); - if ($podcast === null) { - throw NotFoundException::podcast(); - } - - if (!$this->checkHasPublishedEpisodes($podcast)) { - throw NotFoundException::podcast(); - } - - $generatedRss = $this->generateRssFeed($podcast, $station, $request); - - $response->getBody()->write($generatedRss); - - return $response - ->withHeader('Content-Type', 'application/rss+xml') - ->withHeader('X-Robots-Tag', 'index, nofollow'); - } - - private function checkHasPublishedEpisodes(Podcast $podcast): bool - { - /** @var PodcastEpisode $episode */ - foreach ($podcast->getEpisodes() as $episode) { - if ($episode->isPublished()) { - return true; - } - } - - return false; - } - - private function generateRssFeed( - Podcast $podcast, - Station $station, - ServerRequest $serverRequest - ): string { - $rssWriter = $this->createRssWriter(); - - $channel = $this->buildRssChannelForPodcast($podcast, $station, $serverRequest); - - return $rssWriter->writeChannel($channel); - } - - private function createRssWriter(): RssWriter - { + // Create RSS Writer $rssWriter = new RssWriter(null, [], true); - $rssWriter->registerWriter(new CoreWriter()); $rssWriter->registerWriter(new ItunesWriter()); $rssWriter->registerWriter(new SyWriter()); $rssWriter->registerWriter(new SlashWriter()); $rssWriter->registerWriter(new AtomWriter()); - return $rssWriter; - } - - private function buildRssChannelForPodcast( - Podcast $podcast, - Station $station, - ServerRequest $serverRequest - ): RssChannel { $channel = new RssChannel(); - $channel->setTtl(5); $channel->setLastBuildDate(new DateTime()); - $channel->setTitle($podcast->getTitle()); - $channel->setDescription($podcast->getDescription()); + // Fetch podcast API feed. + $podcastApi = $this->podcastApiGenerator->__invoke($podcast, $request); - $channelLink = $podcast->getLink(); - if (empty($channelLink)) { - $channelLink = $serverRequest->getRouter()->fromHere( - routeName: 'public:podcast', - absolute: true - ); + $channel->setTitle($podcastApi->title); + $channel->setDescription($podcastApi->description); + $channel->setLink($podcastApi->link ?? $podcastApi->links['self']); + $channel->setLanguage($podcastApi->language); + + $channel->setCategories( + $podcast->getCategories()->map( + function (PodcastCategory $podcastCategory) { + $rssCategory = new RssCategory(); + if (null === $podcastCategory->getSubTitle()) { + $rssCategory->setTitle($podcastCategory->getTitle()); + } else { + $rssCategory->setTitle($podcastCategory->getSubTitle()); + } + return $rssCategory; + } + )->getValues() + ); + + $rssImage = new RssImage(); + $rssImage->setTitle($podcastApi->title); + $rssImage->setUrl($podcastApi->art); + if (null !== $podcastApi->link) { + $rssImage->setLink($podcastApi->link); } - $channel->setLink($channelLink); - $channel->setLanguage($podcast->getLanguage()); - - $categories = $this->buildRssCategoriesForPodcast($podcast); - $channel->setCategories($categories); - - $rssImage = $this->buildRssImageForPodcast($podcast, $station); $channel->setImage($rssImage); - $rssItems = $this->buildRssItemsForPodcast($podcast, $station); + // Iterate through episodes. + $rssItems = []; + + $hasPublishedEpisode = false; + $hasExplicitEpisode = false; + + /** @var PodcastEpisode $episode */ + foreach ($podcast->getEpisodes() as $episode) { + if (!$episode->isPublished()) { + continue; + } + + $hasPublishedEpisode = true; + if ($episode->getExplicit()) { + $hasExplicitEpisode = true; + } + + $episodeApi = $this->episodeApiGenerator->__invoke($episode, $request); + + $rssItem = new RssItem(); + + $rssItem->setGuid((new RssGuid())->setGuid($episodeApi->id)); + $rssItem->setTitle($episodeApi->title); + $rssItem->setDescription($episodeApi->description); + $rssItem->setLink($episodeApi->link ?? $episodeApi->links['self']); + + $rssItem->setPubDate((new DateTime())->setTimestamp($episode->getPublishAt())); + + $rssEnclosure = new RssEnclosure(); + $rssEnclosure->setUrl($episodeApi->links['download']); + + $podcastMedia = $episode->getMedia(); + if (null !== $podcastMedia) { + $rssEnclosure->setType($podcastMedia->getMimeType()); + $rssEnclosure->setLength($podcastMedia->getLength()); + } + $rssItem->setEnclosure($rssEnclosure); + + $rssItem->addExtension( + (new ItunesItem()) + ->setExplicit($episode->getExplicit()) + ->setImage($episodeApi->art) + ); + + $rssItems[] = $rssItem; + } + + if (!$hasPublishedEpisode) { + throw NotFoundException::podcast(); + } + $channel->setItems($rssItems); - $containsExplicitContent = $this->rssItemsContainsExplicitContent($rssItems); - $itunesChannel = new ItunesChannel(); - $itunesChannel->setExplicit($containsExplicitContent); + $itunesChannel->setExplicit($hasExplicitEpisode); $itunesChannel->setImage($rssImage->getUrl()); - $itunesChannel->setCategories($this->buildItunesCategoriesForPodcast($podcast)); + $itunesChannel->setCategories( + $podcast->getCategories()->map( + function (PodcastCategory $podcastCategory) { + return (null === $podcastCategory->getSubTitle()) + ? $podcastCategory->getTitle() + : [ + $podcastCategory->getTitle(), + $podcastCategory->getSubTitle(), + ]; + } + )->getValues() + ); + $itunesChannel->setOwner($this->buildItunesOwner($podcast)); $itunesChannel->setAuthor($podcast->getAuthor()); @@ -169,46 +175,17 @@ final class PodcastFeedAction implements SingleActionInterface $channel->addExtension( (new AtomLink()) ->setRel('self') - ->setHref((string)$serverRequest->getUri()) + ->setHref((string)$request->getUri()) ->setType('application/rss+xml') ); - return $channel; - } + $response->getBody()->write( + $rssWriter->writeChannel($channel) + ); - /** - * @return RssCategory[] - */ - private function buildRssCategoriesForPodcast(Podcast $podcast): array - { - return $podcast->getCategories()->map( - function (PodcastCategory $podcastCategory) { - $rssCategory = new RssCategory(); - if (null === $podcastCategory->getSubTitle()) { - $rssCategory->setTitle($podcastCategory->getTitle()); - } else { - $rssCategory->setTitle($podcastCategory->getSubTitle()); - } - return $rssCategory; - } - )->getValues(); - } - - /** - * @return mixed[] - */ - private function buildItunesCategoriesForPodcast(Podcast $podcast): array - { - return $podcast->getCategories()->map( - function (PodcastCategory $podcastCategory) { - return (null === $podcastCategory->getSubTitle()) - ? $podcastCategory->getTitle() - : [ - $podcastCategory->getTitle(), - $podcastCategory->getSubTitle(), - ]; - } - )->getValues(); + return $response + ->withHeader('Content-Type', 'application/rss+xml') + ->withHeader('X-Robots-Tag', 'index, nofollow'); } private function buildItunesOwner(Podcast $podcast): ?ItunesOwner @@ -223,171 +200,4 @@ final class PodcastFeedAction implements SingleActionInterface return $itunesOwner; } - - private function buildRssImageForPodcast(Podcast $podcast, Station $station): RssImage - { - $podcastsFilesystem = $this->stationFilesystems->getPodcastsFilesystem($station); - - $rssImage = new RssImage(); - - $podcastArtworkSrc = (string)UriResolver::resolve( - $this->router->getBaseUrl(), - $this->stationRepository->getDefaultAlbumArtUrl($station) - ); - - if ($podcastsFilesystem->fileExists(Podcast::getArtPath($podcast->getIdRequired()))) { - $routeParams = [ - 'podcast_id' => $podcast->getIdRequired(), - ]; - if (0 !== $podcast->getArtUpdatedAt()) { - $routeParams['timestamp'] = $podcast->getArtUpdatedAt(); - } - - $podcastArtworkSrc = $this->router->fromHere( - routeName: 'api:stations:public:podcast:art', - routeParams: $routeParams, - absolute: true - ); - } - - $rssImage->setUrl($podcastArtworkSrc); - - if (null !== $podcast->getLink()) { - $rssImage->setLink($podcast->getLink()); - } - - $rssImage->setTitle($podcast->getTitle()); - - return $rssImage; - } - - /** - * @return RssItem[] - */ - private function buildRssItemsForPodcast(Podcast $podcast, Station $station): array - { - $rssItems = []; - - /** @var PodcastEpisode $episode */ - foreach ($podcast->getEpisodes() as $episode) { - if (!$episode->isPublished()) { - continue; - } - - $rssItem = new RssItem(); - - $rssGuid = new RssGuid(); - $rssGuid->setGuid($episode->getIdRequired()); - - $rssItem->setGuid($rssGuid); - $rssItem->setTitle($episode->getTitle()); - $rssItem->setDescription($episode->getDescription()); - - $episodeLink = $episode->getLink(); - if (empty($episodeLink)) { - $episodeLink = $this->router->fromHere( - routeName: 'public:podcast:episode', - routeParams: ['episode_id' => $episode->getId()], - absolute: true - ); - } - - $rssItem->setLink($episodeLink); - - $publishAtDateTime = (new DateTime())->setTimestamp($episode->getCreatedAt()); - - if ($episode->getPublishAt() !== null) { - $publishAtDateTime = (new DateTime())->setTimestamp($episode->getPublishAt()); - } - - $rssItem->setPubDate($publishAtDateTime); - - $rssEnclosure = $this->buildRssEnclosureForPodcastMedia( - $episode, - $station - ); - $rssItem->setEnclosure($rssEnclosure); - - $itunesImage = $this->buildItunesImageForEpisode($episode, $station); - $rssItem->addExtension( - (new ItunesItem()) - ->setExplicit($episode->getExplicit()) - ->setImage($itunesImage) - ); - - $rssItems[] = $rssItem; - } - - return $rssItems; - } - - private function buildRssEnclosureForPodcastMedia( - PodcastEpisode $episode, - Station $station - ): RssEnclosure { - $rssEnclosure = new RssEnclosure(); - - $podcastMediaPlayUrl = $this->router->fromHere( - routeName: 'api:stations:public:podcast:episode:download', - routeParams: ['episode_id' => $episode->getId()], - absolute: true - ); - - $rssEnclosure->setUrl($podcastMediaPlayUrl); - - $podcastMedia = $episode->getMedia(); - if (null !== $podcastMedia) { - $rssEnclosure->setType($podcastMedia->getMimeType()); - $rssEnclosure->setLength($podcastMedia->getLength()); - } - - return $rssEnclosure; - } - - private function buildItunesImageForEpisode(PodcastEpisode $episode, Station $station): string - { - $podcastsFilesystem = $this->stationFilesystems->getPodcastsFilesystem($station); - - $episodeArtworkSrc = (string)UriResolver::resolve( - $this->router->getBaseUrl(), - $this->stationRepository->getDefaultAlbumArtUrl($station) - ); - - if ($podcastsFilesystem->fileExists(PodcastEpisode::getArtPath($episode->getIdRequired()))) { - $routeParams = [ - 'episode_id' => $episode->getId(), - ]; - if (0 !== $episode->getArtUpdatedAt()) { - $routeParams['timestamp'] = $episode->getArtUpdatedAt(); - } - - $episodeArtworkSrc = $this->router->fromHere( - routeName: 'api:stations:podcast:episode:art', - routeParams: $routeParams, - absolute: true - ); - } - - return $episodeArtworkSrc; - } - - /** - * @param RssItem[] $rssItems - */ - private function rssItemsContainsExplicitContent(array $rssItems): bool - { - foreach ($rssItems as $rssItem) { - foreach ($rssItem->getExtensions() as $extension) { - if (!($extension instanceof ItunesItem)) { - continue; - } - - if ($extension->getExplicit()) { - return true; - } - } - } - - return false; - } } diff --git a/src/Entity/Api/PodcastEpisode.php b/src/Entity/Api/PodcastEpisode.php index 7d6d62198..173a50d3a 100644 --- a/src/Entity/Api/PodcastEpisode.php +++ b/src/Entity/Api/PodcastEpisode.php @@ -21,6 +21,9 @@ final class PodcastEpisode #[OA\Property] public string $title; + #[OA\Property] + public ?string $link = null; + #[OA\Property] public string $description; diff --git a/src/Entity/ApiGenerator/PodcastEpisodeApiGenerator.php b/src/Entity/ApiGenerator/PodcastEpisodeApiGenerator.php index f75274751..f18ec2933 100644 --- a/src/Entity/ApiGenerator/PodcastEpisodeApiGenerator.php +++ b/src/Entity/ApiGenerator/PodcastEpisodeApiGenerator.php @@ -12,6 +12,7 @@ use App\Entity\PodcastMedia; use App\Entity\StationMedia; use App\Http\ServerRequest; use App\Utilities\Strings; +use Symfony\Component\Filesystem\Path; final class PodcastEpisodeApiGenerator { @@ -34,6 +35,8 @@ final class PodcastEpisodeApiGenerator $return->id = $record->getIdRequired(); $return->title = $record->getTitle(); + $return->link = $record->getLink(); + $return->description = $record->getDescription(); $return->description_short = Strings::truncateText($return->description, 100); @@ -41,6 +44,8 @@ final class PodcastEpisodeApiGenerator $return->created_at = $record->getCreatedAt(); $return->publish_at = $record->getPublishAt(); + $mediaExtension = ''; + switch ($podcast->getSource()) { case PodcastSources::Playlist: $return->media = null; @@ -51,6 +56,8 @@ final class PodcastEpisodeApiGenerator $return->playlist_media = $this->songApiGen->__invoke($playlistMediaRow); $return->playlist_media_id = $playlistMediaRow->getUniqueId(); + + $mediaExtension = Path::getExtension($playlistMediaRow->getPath()); } else { $return->has_media = false; @@ -75,6 +82,8 @@ final class PodcastEpisodeApiGenerator $return->has_media = true; $return->media = $media; + + $mediaExtension = Path::getExtension($mediaRow->getPath()); } else { $return->has_media = false; $return->media = null; @@ -98,6 +107,11 @@ final class PodcastEpisodeApiGenerator $artRouteParams['timestamp'] = $return->art_updated_at; } + $downloadRouteParams = $baseRouteParams; + if ('' !== $mediaExtension) { + $downloadRouteParams['extension'] = $mediaExtension; + } + $return->art = $router->named( routeName: 'api:stations:public:podcast:episode:art', routeParams: $artRouteParams, @@ -117,7 +131,7 @@ final class PodcastEpisodeApiGenerator ), 'download' => $router->fromHere( routeName: 'api:stations:public:podcast:episode:download', - routeParams: $baseRouteParams, + routeParams: $downloadRouteParams, absolute: !$isInternal ), ];