#7009 -- Make RSS feed use API generator, add extension to download for iTunes.

This commit is contained in:
Buster Neece 2024-03-21 09:27:52 -05:00
parent 3410442f24
commit b144860d63
No known key found for this signature in database
5 changed files with 125 additions and 297 deletions

View File

@ -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');
}

View File

@ -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)

View File

@ -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;
}
}

View File

@ -21,6 +21,9 @@ final class PodcastEpisode
#[OA\Property]
public string $title;
#[OA\Property]
public ?string $link = null;
#[OA\Property]
public string $description;

View File

@ -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
),
];