diff --git a/config/routes/api_station.php b/config/routes/api_station.php index 32394017b..4c95b9e19 100644 --- a/config/routes/api_station.php +++ b/config/routes/api_station.php @@ -49,6 +49,9 @@ return static function (RouteCollectorProxy $group) { ->add(new Middleware\RateLimit('ondemand', 1, 2)); // Podcast Public Pages + $group->get('/podcasts', Controller\Api\Stations\PodcastsController::class . ':listAction') + ->setName('api:stations:podcasts'); + $group->group( '/podcast/{podcast_id}', function (RouteCollectorProxy $group) { @@ -145,9 +148,6 @@ return static function (RouteCollectorProxy $group) { $group->group( '', function (RouteCollectorProxy $group) { - $group->get('/podcasts', Controller\Api\Stations\PodcastsController::class . ':listAction') - ->setName('api:stations:podcasts'); - $group->post( '/podcasts', Controller\Api\Stations\PodcastsController::class . ':createAction' diff --git a/config/routes/public.php b/config/routes/public.php index 55d3437c2..aef1d85b9 100644 --- a/config/routes/public.php +++ b/config/routes/public.php @@ -57,20 +57,16 @@ return static function (RouteCollectorProxy $app) { $group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class) ->setName('public:schedule'); - $group->get('/podcasts', Controller\Frontend\PublicPages\PodcastsAction::class) - ->setName('public:podcasts'); + $routes = [ + 'public:podcasts' => '/podcasts', + 'public:podcast' => '/podcast/{podcast_id}', + 'public:podcast:episode' => '/podcast/{podcast_id}/episode/{episode_id}', + ]; - $group->get( - '/podcast/{podcast_id}/episodes', - Controller\Frontend\PublicPages\PodcastEpisodesAction::class - ) - ->setName('public:podcast:episodes'); - - $group->get( - '/podcast/{podcast_id}/episode/{episode_id}', - Controller\Frontend\PublicPages\PodcastEpisodeAction::class - ) - ->setName('public:podcast:episode'); + foreach ($routes as $routeName => $routePath) { + $group->get($routePath, Controller\Frontend\PublicPages\PodcastsAction::class) + ->setName($routeName); + } $group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class) ->setName('public:podcast:feed'); diff --git a/frontend/src/components/Public/Podcasts/Podcast.vue b/frontend/src/components/Public/Podcasts/Podcast.vue new file mode 100644 index 000000000..a967476ff --- /dev/null +++ b/frontend/src/components/Public/Podcasts/Podcast.vue @@ -0,0 +1,180 @@ + + + diff --git a/frontend/src/components/Public/Podcasts/PodcastEpisode.vue b/frontend/src/components/Public/Podcasts/PodcastEpisode.vue new file mode 100644 index 000000000..e51c7c691 --- /dev/null +++ b/frontend/src/components/Public/Podcasts/PodcastEpisode.vue @@ -0,0 +1,159 @@ + + + diff --git a/frontend/src/components/Public/Podcasts/PodcastList.vue b/frontend/src/components/Public/Podcasts/PodcastList.vue new file mode 100644 index 000000000..0c958e22a --- /dev/null +++ b/frontend/src/components/Public/Podcasts/PodcastList.vue @@ -0,0 +1,85 @@ + + + diff --git a/frontend/src/components/Public/Podcasts/PodcastsLayout.vue b/frontend/src/components/Public/Podcasts/PodcastsLayout.vue new file mode 100644 index 000000000..9cea358f3 --- /dev/null +++ b/frontend/src/components/Public/Podcasts/PodcastsLayout.vue @@ -0,0 +1,32 @@ + + diff --git a/frontend/src/components/Public/Podcasts/routes.ts b/frontend/src/components/Public/Podcasts/routes.ts new file mode 100644 index 000000000..9325a52dc --- /dev/null +++ b/frontend/src/components/Public/Podcasts/routes.ts @@ -0,0 +1,21 @@ +import {RouteRecordRaw} from "vue-router"; + +export default function usePodcastRoutes(): RouteRecordRaw[] { + return [ + { + path: '/podcasts', + component: () => import('~/components/Public/Podcasts/PodcastList.vue'), + name: 'public:podcasts' + }, + { + path: '/podcast/:podcast_id', + component: () => import('~/components/Public/Podcasts/Podcast.vue'), + name: 'public:podcast' + }, + { + path: '/podcast/:podcast_id/episode/:episode_id', + component: () => import('~/components/Public/Podcasts/PodcastEpisode.vue'), + name: 'public:podcast:episode' + } + ]; +} diff --git a/frontend/src/components/Stations/Playlists.vue b/frontend/src/components/Stations/Playlists.vue index a15d2c3b9..48a07c660 100644 --- a/frontend/src/components/Stations/Playlists.vue +++ b/frontend/src/components/Stations/Playlists.vue @@ -49,8 +49,8 @@
{{ row.item.name }}
-
- +
+ @@ -60,31 +60,31 @@ {{ $gettext('Jingle Mode') }} {{ $gettext('Sequential') }} {{ $gettext('On-Demand') }} {{ $gettext('Scheduled') }} {{ $gettext('Disabled') }} diff --git a/frontend/src/js/pages/Public/Podcasts.js b/frontend/src/js/pages/Public/Podcasts.js new file mode 100644 index 000000000..1be8e3d1d --- /dev/null +++ b/frontend/src/js/pages/Public/Podcasts.js @@ -0,0 +1,24 @@ +import initApp from "~/layout"; +import {h} from "vue"; +import {createRouter, createWebHistory} from "vue-router"; +import {useAzuraCast} from "~/vendor/azuracast"; +import {installRouter} from "~/vendor/router"; +import PodcastsLayout from "~/components/Public/Podcasts/PodcastsLayout.vue"; +import usePodcastRoutes from "~/components/Public/Podcasts/routes"; + +initApp({ + render() { + return h(PodcastsLayout); + } +}, async (vueApp) => { + const routes = usePodcastRoutes(); + const {componentProps} = useAzuraCast(); + + installRouter( + createRouter({ + history: createWebHistory(componentProps.baseUrl), + routes + }), + vueApp + ); +}); diff --git a/src/Controller/Api/Stations/PodcastEpisodesController.php b/src/Controller/Api/Stations/PodcastEpisodesController.php index 27b3ffe55..49700cca7 100644 --- a/src/Controller/Api/Stations/PodcastEpisodesController.php +++ b/src/Controller/Api/Stations/PodcastEpisodesController.php @@ -17,6 +17,7 @@ use App\Http\Response; use App\Http\ServerRequest; use App\OpenApi; use App\Service\Flow\UploadedFile; +use App\Utilities\Strings; use App\Utilities\Types; use InvalidArgumentException; use OpenApi\Attributes as OA; @@ -222,6 +223,15 @@ final class PodcastEpisodesController extends AbstractApiCrudController ->orderBy('e.created_at', 'DESC') ->setParameter('podcast', $podcast); + $acl = $request->getAcl(); + if (!$acl->isAllowed(StationPermissions::Podcasts, $station)) { + $queryBuilder = $queryBuilder + ->andWhere('e.publish_at IS NULL OR e.publish_at <= :publishTime') + ->setParameter('publishTime', time()) + ->andWhere('pm.id IS NOT NULL') + ->orderBy('e.publish_at', 'DESC'); + } + $queryBuilder = $this->searchQueryBuilder( $request, $queryBuilder, @@ -309,10 +319,14 @@ final class PodcastEpisodesController extends AbstractApiCrudController $router = $request->getRouter(); $return = new ApiPodcastEpisode(); - $return->id = $record->getId(); + $return->id = $record->getIdRequired(); $return->title = $record->getTitle(); + $return->description = $record->getDescription(); + $return->description_short = Strings::truncateText($return->description, 100); + $return->explicit = $record->getExplicit(); + $return->created_at = $record->getCreatedAt(); $return->publish_at = $record->getPublishAt(); $mediaRow = $record->getMedia(); diff --git a/src/Controller/Api/Stations/PodcastsController.php b/src/Controller/Api/Stations/PodcastsController.php index 78e965337..522460486 100644 --- a/src/Controller/Api/Stations/PodcastsController.php +++ b/src/Controller/Api/Stations/PodcastsController.php @@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations; use App\Controller\Api\AbstractApiCrudController; use App\Controller\Api\Traits\CanSearchResults; use App\Entity\Api\Podcast as ApiPodcast; +use App\Entity\Api\PodcastCategory as ApiPodcastCategory; use App\Entity\Podcast; use App\Entity\PodcastCategory; use App\Entity\Repository\PodcastRepository; @@ -15,10 +16,13 @@ use App\Http\Response; use App\Http\ServerRequest; use App\OpenApi; use App\Service\Flow\UploadedFile; +use App\Utilities\Strings; use App\Utilities\Types; use InvalidArgumentException; use OpenApi\Attributes as OA; use Psr\Http\Message\ResponseInterface; +use Symfony\Component\Intl\Exception\MissingResourceException; +use Symfony\Component\Intl\Languages; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -239,18 +243,40 @@ final class PodcastsController extends AbstractApiCrudController $station = $request->getStation(); $return = new ApiPodcast(); - $return->id = $record->getId(); - $return->storage_location_id = $record->getStorageLocation()->getId(); + $return->id = $record->getIdRequired(); + $return->storage_location_id = $record->getStorageLocation()->getIdRequired(); + $return->title = $record->getTitle(); $return->link = $record->getLink(); + $return->description = $record->getDescription(); + $return->description_short = Strings::truncateText($return->description, 200); + $return->language = $record->getLanguage(); + try { + $locale = $request->getCustomization()->getLocale(); + $return->language_name = Languages::getName( + $return->language, + $locale->value + ); + } catch (MissingResourceException) { + } + $return->author = $record->getAuthor(); $return->email = $record->getEmail(); $categories = []; foreach ($record->getCategories() as $category) { - $categories[] = $category->getCategory(); + $categoryRow = new ApiPodcastCategory(); + $categoryRow->category = $category->getCategory(); + $categoryRow->title = $category->getTitle(); + $categoryRow->subtitle = $category->getSubTitle(); + + $categoryRow->text = (!empty($categoryRow->subtitle)) + ? $categoryRow->title . ' - ' . $categoryRow->subtitle + : $categoryRow->title; + + $categories[] = $categoryRow; } $return->categories = $categories; @@ -287,7 +313,7 @@ final class PodcastsController extends AbstractApiCrudController absolute: !$isInternal ), 'public_episodes' => $router->fromHere( - routeName: 'public:podcast:episodes', + routeName: 'public:podcast', routeParams: ['podcast_id' => $record->getId()], absolute: !$isInternal ), diff --git a/src/Controller/Frontend/PublicPages/PodcastEpisodeAction.php b/src/Controller/Frontend/PublicPages/PodcastEpisodeAction.php deleted file mode 100644 index 45e6d48fc..000000000 --- a/src/Controller/Frontend/PublicPages/PodcastEpisodeAction.php +++ /dev/null @@ -1,85 +0,0 @@ -getRouter(); - $station = $request->getStation(); - - if (!$station->getEnablePublicPage()) { - throw NotFoundException::station(); - } - - $podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcastId); - - if ($podcast === null) { - throw NotFoundException::podcast(); - } - - $episode = $this->episodeRepository->fetchEpisodeForStation($station, $episodeId); - - $podcastEpisodesLink = $router->named( - 'public:podcast:episodes', - [ - 'station_id' => $station->getId(), - 'podcast_id' => $podcastId, - ] - ); - - if (!($episode instanceof PodcastEpisode) || !$episode->isPublished()) { - $request->getFlash()->error(__('Episode not found.')); - return $response->withRedirect($podcastEpisodesLink); - } - - $feedLink = $router->named( - 'public:podcast:feed', - [ - 'station_id' => $station->getId(), - 'podcast_id' => $podcast->getId(), - ] - ); - - return $request->getView()->renderToResponse( - $response - ->withHeader('X-Frame-Options', '*') - ->withHeader('X-Robots-Tag', 'index, nofollow'), - 'frontend/public/podcast-episode', - [ - 'episode' => $episode, - 'feedLink' => $feedLink, - 'podcast' => $podcast, - 'podcastEpisodesLink' => $podcastEpisodesLink, - 'station' => $station, - ] - ); - } -} diff --git a/src/Controller/Frontend/PublicPages/PodcastEpisodesAction.php b/src/Controller/Frontend/PublicPages/PodcastEpisodesAction.php deleted file mode 100644 index 53b05b9d8..000000000 --- a/src/Controller/Frontend/PublicPages/PodcastEpisodesAction.php +++ /dev/null @@ -1,95 +0,0 @@ -getRouter(); - $station = $request->getStation(); - - if (!$station->getEnablePublicPage()) { - throw NotFoundException::station(); - } - - $podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcastId); - - if ($podcast === null) { - throw NotFoundException::podcast(); - } - - $publishedEpisodes = $this->episodeRepository->fetchPublishedEpisodesForPodcast($podcast); - - // Reverse sort order according to the calculated publishing timestamp - usort( - $publishedEpisodes, - static function ($prevEpisode, $nextEpisode) { - /** @var PodcastEpisode $prevEpisode */ - /** @var PodcastEpisode $nextEpisode */ - - $prevPublishedAt = $prevEpisode->getPublishAt() ?? $prevEpisode->getCreatedAt(); - $nextPublishedAt = $nextEpisode->getPublishAt() ?? $nextEpisode->getCreatedAt(); - - return ($nextPublishedAt <=> $prevPublishedAt); - } - ); - - $podcastsLink = $router->fromHere( - 'public:podcasts', - [ - 'station_id' => $station->getId(), - ] - ); - - if (count($publishedEpisodes) === 0) { - $request->getFlash()->error(__('No episodes found.')); - return $response->withRedirect($podcastsLink); - } - - $feedLink = $router->named( - 'public:podcast:feed', - [ - 'station_id' => $station->getId(), - 'podcast_id' => $podcast->getId(), - ] - ); - - return $request->getView()->renderToResponse( - $response - ->withHeader('X-Frame-Options', '*') - ->withHeader('X-Robots-Tag', 'index, nofollow'), - 'frontend/public/podcast-episodes', - [ - 'episodes' => $publishedEpisodes, - 'feedLink' => $feedLink, - 'podcast' => $podcast, - 'podcastsLink' => $podcastsLink, - 'station' => $station, - ] - ); - } -} diff --git a/src/Controller/Frontend/PublicPages/PodcastsAction.php b/src/Controller/Frontend/PublicPages/PodcastsAction.php index 5622d52e8..f01fcaf24 100644 --- a/src/Controller/Frontend/PublicPages/PodcastsAction.php +++ b/src/Controller/Frontend/PublicPages/PodcastsAction.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Controller\Frontend\PublicPages; +use App\Controller\Frontend\PublicPages\Traits\IsEmbeddable; use App\Controller\SingleActionInterface; -use App\Entity\Repository\PodcastRepository; use App\Exception\NotFoundException; use App\Http\Response; use App\Http\ServerRequest; @@ -13,10 +13,7 @@ use Psr\Http\Message\ResponseInterface; final class PodcastsAction implements SingleActionInterface { - public function __construct( - private readonly PodcastRepository $podcastRepository - ) { - } + use IsEmbeddable; public function __invoke( ServerRequest $request, @@ -29,17 +26,37 @@ final class PodcastsAction implements SingleActionInterface throw NotFoundException::station(); } - $publishedPodcasts = $this->podcastRepository->fetchPublishedPodcastsForStation($station); + $pageClass = 'podcasts station-' . $station->getShortName(); + if ($this->isEmbedded($request, $params)) { + $pageClass .= ' embed'; + } - return $request->getView()->renderToResponse( - $response + $router = $request->getRouter(); + $view = $request->getView(); + + // Add station public code. + $view->fetch( + 'frontend/public/partials/station-custom', + ['station' => $station] + ); + + return $view->renderVuePage( + response: $response ->withHeader('X-Frame-Options', '*') ->withHeader('X-Robots-Tag', 'index, nofollow'), - 'frontend/public/podcasts', - [ - 'podcasts' => $publishedPodcasts, - 'station' => $station, - ] + component: 'Public/Podcasts', + id: 'podcast', + layout: 'minimal', + title: 'Podcasts - ' . $station->getName(), + layoutParams: [ + 'page_class' => $pageClass, + 'hide_footer' => true, + ], + props: [ + 'baseUrl' => $router->named('public:index', [ + 'station_id' => $station->getShortName(), + ]), + ], ); } } diff --git a/src/Entity/Api/Podcast.php b/src/Entity/Api/Podcast.php index 00a74ade4..76d7357fd 100644 --- a/src/Entity/Api/Podcast.php +++ b/src/Entity/Api/Podcast.php @@ -16,41 +16,50 @@ final class Podcast use HasLinks; #[OA\Property] - public ?string $id = null; + public string $id; #[OA\Property] - public ?int $storage_location_id = null; + public int $storage_location_id; #[OA\Property] - public ?string $title = null; + public string $title; #[OA\Property] public ?string $link = null; #[OA\Property] - public ?string $description = null; + public string $description; #[OA\Property] - public ?string $language = null; + public string $description_short; #[OA\Property] - public ?string $author = null; + public string $language; #[OA\Property] - public ?string $email = null; + public string $language_name; + + #[OA\Property] + public string $author; + + #[OA\Property] + public string $email; #[OA\Property] public bool $has_custom_art = false; #[OA\Property] - public ?string $art = null; + public string $art; #[OA\Property] public int $art_updated_at = 0; + /** + * @var PodcastCategory[] + */ #[OA\Property( type: 'array', - items: new OA\Items(type: 'string') + items: new OA\Items(type: PodcastCategory::class) )] public array $categories = []; diff --git a/src/Entity/Api/PodcastCategory.php b/src/Entity/Api/PodcastCategory.php new file mode 100644 index 000000000..fd391f9c3 --- /dev/null +++ b/src/Entity/Api/PodcastCategory.php @@ -0,0 +1,26 @@ +getOneOrNullResult(); } - /** - * @return PodcastEpisode[] - */ - public function fetchPublishedEpisodesForPodcast(Podcast $podcast): array - { - $episodes = $this->em->createQueryBuilder() - ->select('pe') - ->from(PodcastEpisode::class, 'pe') - ->where('pe.podcast = :podcast') - ->setParameter('podcast', $podcast) - ->orderBy('pe.created_at', 'DESC') - ->getQuery() - ->getResult(); - - return array_filter( - $episodes, - static function (PodcastEpisode $episode) { - return $episode->isPublished(); - } - ); - } - public function writeEpisodeArt( PodcastEpisode $episode, string $rawArtworkString diff --git a/src/Entity/Repository/PodcastRepository.php b/src/Entity/Repository/PodcastRepository.php index b66a044d6..8b0acb7f8 100644 --- a/src/Entity/Repository/PodcastRepository.php +++ b/src/Entity/Repository/PodcastRepository.php @@ -44,35 +44,6 @@ final class PodcastRepository extends Repository ); } - /** - * @return Podcast[] - */ - public function fetchPublishedPodcastsForStation(Station $station): array - { - $podcasts = $this->em->createQuery( - <<<'DQL' - SELECT p, pe - FROM App\Entity\Podcast p - LEFT JOIN p.episodes pe - WHERE p.storage_location = :storageLocation - DQL - )->setParameter('storageLocation', $station->getPodcastsStorageLocation()) - ->getResult(); - - return array_filter( - $podcasts, - static function (Podcast $podcast) { - foreach ($podcast->getEpisodes() as $episode) { - if ($episode->isPublished()) { - return true; - } - } - - return false; - } - ); - } - public function writePodcastArt( Podcast $podcast, string $rawArtworkString, diff --git a/templates/frontend/public/podcast-episode.phtml b/templates/frontend/public/podcast-episode.phtml deleted file mode 100644 index 6203b7463..000000000 --- a/templates/frontend/public/podcast-episode.phtml +++ /dev/null @@ -1,95 +0,0 @@ -layout('minimal', [ - 'page_class' => 'podcasts station-' . $station->getShortName(), - 'title' => 'Podcasts - ' . $station->getName(), - 'hide_footer' => true, -]); - -$this->fetch('frontend/public/partials/station-custom', ['station' => $station]); - -$episodeAudioSrc = $router->named( - 'api:stations:podcast:episode:download', - [ - 'station_id' => $station->getId(), - 'podcast_id' => $episode->getPodcast()->getId(), - 'episode_id' => $episode->getId(), - ], - [], - true -); - -$publishedAt = CarbonImmutable::createFromTimestamp($episode->getCreatedAt()); - -if ($episode->getPublishAt() !== null) { - $publishedAt = CarbonImmutable::createFromTimestamp($episode->getPublishAt()); -} - -$sections->append( - 'head', - << - HTML -); -?> -
-
-
-
-
-

- e($podcast->getTitle()) ?> -

-

- e($episode->getTitle()) ?> -

- -
- - format('d. M. Y') ?> - - - getExplicit()) : ?> - - -
- -

e($episode->getDescription()) ?>

-
-
- <?= $this->e($podcast->getTitle()) ?> -
-
- -
- -
- - -
-
-
diff --git a/templates/frontend/public/podcast-episodes.phtml b/templates/frontend/public/podcast-episodes.phtml deleted file mode 100644 index 0f66adf86..000000000 --- a/templates/frontend/public/podcast-episodes.phtml +++ /dev/null @@ -1,116 +0,0 @@ -layout( - 'minimal', - [ - 'page_class' => 'podcasts station-' . $station->getShortName(), - 'title' => 'Podcasts - ' . $station->getName(), - 'hide_footer' => true, - ] -); - -$this->fetch('frontend/public/partials/station-custom', ['station' => $station]); - -$sections->append( - 'head', - << - HTML -); -?> - -
-
-
-

- e($podcast->getTitle()) ?> -

-

- -

- - - - - named( - 'public:podcast:episode', - [ - 'station_id' => $station->getId(), - 'podcast_id' => $podcast->getId(), - 'episode_id' => $episode->getId(), - ] - ) ?> -
-
- - <?= $this->e($podcast->getTitle()) ?> - -
-
-
e($episode->getTitle()) ?>
-

e($episode->getDescription()) ?>

- - getExplicit()) : ?> -

- -

- - -

- getCreatedAt() - ); ?> - getPublishAt() !== null) : ?> - getPublishAt() - ); ?> - - format('d. M. Y') ?> -

- -
- - - -
-
-
- -
-
-
diff --git a/templates/frontend/public/podcasts.phtml b/templates/frontend/public/podcasts.phtml deleted file mode 100644 index 651dd5fa2..000000000 --- a/templates/frontend/public/podcasts.phtml +++ /dev/null @@ -1,92 +0,0 @@ -layout('minimal', [ - 'page_class' => 'podcasts station-' . $station->getShortName(), - 'title' => 'Podcasts - ' . $station->getName(), - 'hide_footer' => true, -]); - -$this->fetch('frontend/public/partials/station-custom', ['station' => $station]); - -?> -
-
-
-

- e($station->getName()) ?> -

- - - named( - 'public:podcast:episodes', - [ - 'station_id' => $station->getId(), - 'podcast_id' => $podcast->getId(), - ] - ) ?> - named( - 'public:podcast:feed', - ['station_id' => $station->getId(), 'podcast_id' => $podcast->getId()] - ) ?> -
-
- - <?= $this->e($podcast->getTitle()) ?> - -
-
-
e($podcast->getTitle()) ?>
-

e($podcast->getDescription()) ?>

- -

- : getLanguage() - ) ?> -
- : getCategories()->map( - function ($category) { - $title = $category->getTitle(); - $subtitle = $category->getSubTitle(); - - return (!empty($subtitle)) - ? $title . ' - ' . $subtitle - : $title; - } - )->getValues() - ); ?> -

- - -
-
- - - -

- -
-
-
diff --git a/templates/icons/rss.phtml b/templates/icons/rss.phtml deleted file mode 100644 index 58cc4b415..000000000 --- a/templates/icons/rss.phtml +++ /dev/null @@ -1,7 +0,0 @@ -