Podcast controller overhaul:

- Make public-facing podcast API endpoints separate from the CRUD API used by station operators
 - Make Podcast a request attribute (like stations) to simplify several controllers
  - Move API generation for podcasts and episodes into an ApiGenerator class.
This commit is contained in:
Buster Neece 2024-01-25 16:54:31 -06:00
parent 85f89d13ba
commit f379464937
No known key found for this signature in database
35 changed files with 708 additions and 423 deletions

View File

@ -68,31 +68,67 @@ return static function (RouteCollectorProxy $group) {
$group->get(
'/art/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.jpg]',
Controller\Api\Stations\Art\GetArtAction::class
)->setName('api:stations:media:art');
)->setName('api:stations:media:art')
->add(new Middleware\Cache\SetStaticFileCache());
// Streamer Art
$group->get(
'/streamer/{id}/art[-{timestamp}.jpg]',
Controller\Api\Stations\Streamers\Art\GetArtAction::class
)->setName('api:stations:streamer:art');
)->setName('api:stations:streamer:art')
->add(new Middleware\Cache\SetStaticFileCache());
// Podcast and Episode Art
$group->group(
'/podcast/{podcast_id}',
'/public',
function (RouteCollectorProxy $group) {
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
)->setName('api:stations:podcast:art');
// Podcast Public Pages
$group->get('/podcasts', Controller\Api\Stations\Podcasts\ListPodcastsAction::class)
->setName('api:stations:public:podcasts');
$group->get(
'/episode/{episode_id}/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
)->setName('api:stations:podcast:episode:art');
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Stations\Podcasts\GetPodcastAction::class)
->setName('api:stations:public:podcast');
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
)->setName('api:stations:public:podcast:art')
->add(new Middleware\Cache\SetStaticFileCache());
$group->get(
'/episodes',
Controller\Api\Stations\Podcasts\Episodes\ListEpisodesAction::class
)->setName('api:stations:public:podcast:episodes');
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->get(
'',
Controller\Api\Stations\Podcasts\Episodes\GetEpisodeAction::class
)->setName('api:stations:public:podcast:episode')
->add(new Middleware\Cache\SetStaticFileCache());
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
)->setName('api:stations:public:podcast:episode:art')
->add(new Middleware\Cache\SetStaticFileCache());
$group->get(
'/download',
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:public:podcast:episode:download');
}
);
}
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class)
->add(Middleware\GetAndRequirePodcast::class);
}
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
);
}
)->add(new Middleware\Cache\SetStaticFileCache())
->add(Middleware\RequireStation::class)
)->add(Middleware\RequireStation::class)
->add(Middleware\GetStation::class);
};

View File

@ -48,41 +48,7 @@ return static function (RouteCollectorProxy $group) {
->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand))
->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) {
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
->setName('api:stations:podcast');
// See ./api_public for podcast art.
$group->get(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':listAction'
)->setName('api:stations:podcast:episodes');
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->get(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
)->setName('api:stations:podcast:episode');
$group->get(
'/download',
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:podcast:episode:download');
}
);
}
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
// NOTE: See ./api_public.php for media art public path.
// NOTE: See ./api_public.php for podcast public pages.
/*
* Authenticated Functions
@ -148,6 +114,9 @@ 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'
@ -159,6 +128,9 @@ return static function (RouteCollectorProxy $group) {
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
->setName('api:stations:podcast');
$group->put('', Controller\Api\Stations\PodcastsController::class . ':editAction');
$group->delete(
@ -166,16 +138,26 @@ return static function (RouteCollectorProxy $group) {
Controller\Api\Stations\PodcastsController::class . ':deleteAction'
);
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
)->setName('api:stations:podcast:art');
$group->post(
'/art',
Controller\Api\Stations\Podcasts\Art\PostArtAction::class
)->setName('api:stations:podcast:art-internal');
);
$group->delete(
'/art',
Controller\Api\Stations\Podcasts\Art\DeleteArtAction::class
);
$group->get(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':listAction'
)->setName('api:stations:podcast:episodes');
$group->post(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':createAction'
@ -194,6 +176,11 @@ return static function (RouteCollectorProxy $group) {
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->get(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
)->setName('api:stations:podcast:episode');
$group->put(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':editAction'
@ -205,20 +192,30 @@ return static function (RouteCollectorProxy $group) {
. ':deleteAction'
);
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
)->setName('api:stations:podcast:episode:art');
$group->post(
'/art',
Controller\Api\Stations\Podcasts\Episodes\Art\PostArtAction::class
)->setName('api:stations:podcast:episode:art-internal');
);
$group->delete(
'/art',
Controller\Api\Stations\Podcasts\Episodes\Art\DeleteArtAction::class
);
$group->get(
'/media',
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:podcast:episode:media');
$group->post(
'/media',
Controller\Api\Stations\Podcasts\Episodes\Media\PostMediaAction::class
)->setName('api:stations:podcast:episode:media-internal');
);
$group->delete(
'/media',
@ -227,7 +224,7 @@ return static function (RouteCollectorProxy $group) {
}
);
}
);
)->add(Middleware\GetAndRequirePodcast::class);
}
)->add(new Middleware\Permissions(StationPermissions::Podcasts, true));

View File

@ -145,7 +145,7 @@ import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter
const {params} = useRoute();
const podcastUrl = getStationApiUrl(`/podcast/${params.podcast_id}`);
const podcastUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}`);
const {axios} = useAxios();
const {state: podcast, isLoading} = useRefreshableAsyncState(
@ -153,7 +153,7 @@ const {state: podcast, isLoading} = useRefreshableAsyncState(
{},
);
const episodesUrl = getStationApiUrl(`/podcast/${params.podcast_id}/episodes`);
const episodesUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}/episodes`);
const {$gettext} = useTranslate();
const fields: DataTableField[] = [

View File

@ -127,8 +127,8 @@ import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter
const {params} = useRoute();
const podcastUrl = getStationApiUrl(`/podcast/${params.podcast_id}`);
const episodeUrl = getStationApiUrl(`/podcast/${params.podcast_id}/episode/${params.episode_id}`);
const podcastUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}`);
const episodeUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}/episode/${params.episode_id}`);
const {axios} = useAxios();

View File

@ -73,7 +73,7 @@ import {useTranslate} from "~/vendor/gettext.ts";
import {IconRss} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
const apiUrl = getStationApiUrl('/podcasts');
const apiUrl = getStationApiUrl('/public/podcasts');
const {$gettext} = useTranslate();

View File

@ -37,7 +37,6 @@
<script setup lang="ts">
import {ref, toRef, watch} from "vue";
import {syncRef} from "@vueuse/core";
import {useAxios} from "~/vendor/axios";
import FormGroup from "~/components/Form/FormGroup.vue";
import FormFile from "~/components/Form/FormFile.vue";
@ -49,9 +48,7 @@ const props = defineProps({
}
});
const albumArtSrc = ref(null);
syncRef(toRef(props, 'albumArtUrl'), albumArtSrc, {direction: 'ltr'});
const albumArtSrc = ref(props.albumArtUrl);
const reloadArt = () => {
albumArtSrc.value = props.albumArtUrl + '?' + Math.floor(Date.now() / 1000);
}

View File

@ -45,7 +45,7 @@
</template>
<script setup lang="ts">
import {computed, ref, toRef} from "vue";
import {computed, ref, toRef, watch} from "vue";
import {useAxios} from "~/vendor/axios";
import FormGroup from "~/components/Form/FormGroup.vue";
import FormFile from "~/components/Form/FormFile.vue";
@ -54,15 +54,11 @@ import Tab from "~/components/Common/Tab.vue";
const props = defineProps({
modelValue: {
type: Object,
required: true
required: false
},
artworkSrc: {
type: String,
required: true
},
editArtUrl: {
type: String,
required: true
required: false
},
newArtUrl: {
type: String,
@ -72,7 +68,12 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']);
const artworkSrc = toRef(props, 'artworkSrc');
const artworkSrc = ref(props.artworkSrc);
const reloadArt = () => {
artworkSrc.value = props.artworkSrc + '?' + Math.floor(Date.now() / 1000);
}
watch(toRef(props, 'artworkSrc'), reloadArt);
const localSrc = ref(null);
const src = computed(() => {
@ -92,21 +93,24 @@ const uploaded = (file) => {
}, false);
fileReader.readAsDataURL(file);
const url = (props.editArtUrl) ? props.editArtUrl : props.newArtUrl;
const url = (props.artworkSrc) ? props.artworkSrc : props.newArtUrl;
const formData = new FormData();
formData.append('art', file);
axios.post(url, formData).then((resp) => {
emit('update:modelValue', resp.data);
reloadArt();
});
};
const deleteArt = () => {
if (props.editArtUrl) {
axios.delete(props.editArtUrl).then(() => {
if (props.artworkSrc) {
axios.delete(props.artworkSrc).then(() => {
reloadArt();
localSrc.value = null;
});
} else {
reloadArt();
localSrc.value = null;
}
}

View File

@ -18,14 +18,12 @@
:record-has-media="record.has_media"
:new-media-url="newMediaUrl"
:edit-media-url="record.links.media"
:download-url="record.links.download"
/>
<podcast-common-artwork
v-model="form.artwork_file"
:artwork-src="record.art"
:artwork-src="record.links.art"
:new-art-url="newArtUrl"
:edit-art-url="record.links.art"
/>
</tabs>
</modal-form>

View File

@ -32,9 +32,9 @@
<template v-if="hasMedia">
<div class="block-buttons pt-3">
<a
v-if="downloadUrl"
v-if="editMediaUrl"
class="btn btn-block btn-dark"
:href="downloadUrl"
:href="editMediaUrl"
target="_blank"
>
{{ $gettext('Download') }}
@ -74,10 +74,6 @@ const props = defineProps({
type: Boolean,
required: true
},
downloadUrl: {
type: String,
required: true
},
editMediaUrl: {
type: String,
required: true

View File

@ -33,42 +33,42 @@
:fields="fields"
:api-url="listUrl"
>
<template #cell(art)="row">
<album-art :src="row.item.art" />
<template #cell(art)="{item}">
<album-art :src="item.art" />
</template>
<template #cell(title)="row">
<template #cell(title)="{item}">
<h5 class="m-0">
{{ row.item.title }}
{{ item.title }}
</h5>
<a
:href="row.item.links.public_episodes"
:href="item.links.public_episodes"
target="_blank"
>{{ $gettext('Public Page') }}</a> &bull;
<a
:href="row.item.links.public_feed"
:href="item.links.public_feed"
target="_blank"
>{{ $gettext('RSS Feed') }}</a>
</template>
<template #cell(actions)="row">
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(row.item.links.self)"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(row.item.links.self)"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<button
type="button"
class="btn btn-secondary"
@click="doSelectPodcast(row.item)"
@click="doSelectPodcast(item)"
>
{{ $gettext('Episodes') }}
</button>
@ -88,7 +88,7 @@
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import EditModal from './PodcastEditModal.vue';
import AlbumArt from '~/components/Common/AlbumArt.vue';
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";

View File

@ -17,9 +17,8 @@
<podcast-common-artwork
v-model="form.artwork_file"
:artwork-src="record.art"
:artwork-src="record.links.art"
:new-art-url="newArtUrl"
:edit-art-url="record.links.art"
/>
</tabs>
</modal-form>
@ -35,6 +34,7 @@ import {useResettableRef} from "~/functions/useResettableRef";
import {useTranslate} from "~/vendor/gettext";
import ModalForm from "~/components/Common/ModalForm.vue";
import Tabs from "~/components/Common/Tabs.vue";
import {map} from "lodash";
const props = defineProps({
...baseEditModalProps,
@ -59,7 +59,9 @@ const $modal = ref<ModalFormTemplateRef>(null);
const {record, reset} = useResettableRef({
has_custom_art: false,
art: null,
links: {}
links: {
art: null
}
});
const {
@ -87,6 +89,11 @@ const {
reset();
},
populateForm: (data, formRef) => {
data.categories = map(
data.categories,
(row) => row.category
);
record.value = data;
formRef.value = mergeExisting(formRef.value, data);
},

View File

@ -7,22 +7,16 @@ namespace App\Controller\Api\Stations;
use App\Controller\Api\AbstractApiCrudController;
use App\Controller\Api\Traits\CanSearchResults;
use App\Entity\Api\PodcastEpisode as ApiPodcastEpisode;
use App\Entity\Api\PodcastMedia as ApiPodcastMedia;
use App\Entity\ApiGenerator\PodcastEpisodeApiGenerator;
use App\Entity\PodcastEpisode;
use App\Entity\PodcastMedia;
use App\Entity\Repository\PodcastEpisodeRepository;
use App\Entity\Repository\PodcastRepository;
use App\Enums\StationPermissions;
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 RuntimeException;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@ -194,8 +188,8 @@ final class PodcastEpisodesController extends AbstractApiCrudController
protected string $resourceRouteName = 'api:stations:podcast:episode';
public function __construct(
private readonly PodcastRepository $podcastRepository,
private readonly PodcastEpisodeRepository $episodeRepository,
private readonly PodcastEpisodeApiGenerator $episodeApiGen,
Serializer $serializer,
ValidatorInterface $validator,
) {
@ -207,12 +201,7 @@ final class PodcastEpisodesController extends AbstractApiCrudController
Response $response,
array $params
): ResponseInterface {
/** @var string $podcastId */
$podcastId = $params['podcast_id'];
$station = $request->getStation();
$podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcastId);
$podcast = $request->getPodcast();
$queryBuilder = $this->em->createQueryBuilder()
->select('e, p, pm')
@ -223,15 +212,6 @@ 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,
@ -251,8 +231,8 @@ final class PodcastEpisodesController extends AbstractApiCrudController
/** @var string $id */
$id = $params['episode_id'];
return $this->episodeRepository->fetchEpisodeForStation(
$request->getStation(),
return $this->episodeRepository->fetchEpisodeForPodcast(
$request->getPodcast(),
$id
);
}
@ -260,17 +240,7 @@ final class PodcastEpisodesController extends AbstractApiCrudController
protected function createRecord(ServerRequest $request, array $data): object
{
$station = $request->getStation();
$podcastId = $request->getAttribute('podcast_id');
$podcast = $this->podcastRepository->fetchPodcastForStation(
$station,
$podcastId
);
if (null === $podcast) {
throw new RuntimeException('Podcast not found.');
}
$podcast = $request->getPodcast();
$record = $this->editRecord(
$data,
@ -315,87 +285,47 @@ final class PodcastEpisodesController extends AbstractApiCrudController
throw new InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass));
}
$isInternal = Types::bool($request->getParam('internal'), false, true);
$isInternal = $request->isInternal();
$router = $request->getRouter();
$return = new ApiPodcastEpisode();
$return->id = $record->getIdRequired();
$return->title = $record->getTitle();
$return = $this->episodeApiGen->__invoke($record, $request);
$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();
$return->has_media = ($mediaRow instanceof PodcastMedia);
if ($mediaRow instanceof PodcastMedia) {
$media = new ApiPodcastMedia();
$media->id = $mediaRow->getId();
$media->original_name = $mediaRow->getOriginalName();
$media->length = $mediaRow->getLength();
$media->length_text = $mediaRow->getLengthText();
$media->path = $mediaRow->getPath();
$return->has_media = true;
$return->media = $media;
} else {
$return->has_media = false;
$return->media = new ApiPodcastMedia();
}
$return->art_updated_at = $record->getArtUpdatedAt();
$return->has_custom_art = (0 !== $return->art_updated_at);
$routeParams = [
'episode_id' => $record->getId(),
$baseRouteParams = [
'station_id' => $request->getStation()->getIdRequired(),
'podcast_id' => $record->getPodcast()->getIdRequired(),
'episode_id' => $record->getIdRequired(),
];
if ($return->has_custom_art) {
$routeParams['timestamp'] = $return->art_updated_at;
$artRouteParams = $baseRouteParams;
if (0 !== $return->art_updated_at) {
$artRouteParams['timestamp'] = $return->art_updated_at;
}
$return->art = $router->fromHere(
$return->art = $router->named(
routeName: 'api:stations:podcast:episode:art',
routeParams: $routeParams,
absolute: true
routeParams: $artRouteParams,
absolute: !$isInternal
);
$return->links = [
...$return->links,
'self' => $router->fromHere(
routeName: $this->resourceRouteName,
routeParams: ['episode_id' => $record->getId()],
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'public' => $router->fromHere(
routeName: 'public:podcast:episode',
routeParams: ['episode_id' => $record->getId()],
'art' => $router->named(
routeName: 'api:stations:podcast:episode:art',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'download' => $router->fromHere(
routeName: 'api:stations:podcast:episode:download',
routeParams: ['episode_id' => $record->getId()],
'media' => $router->fromHere(
routeName: 'api:stations:podcast:episode:media',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
];
$acl = $request->getAcl();
$station = $request->getStation();
if ($acl->isAllowed(StationPermissions::Podcasts, $station)) {
$return->links['art'] = $router->fromHere(
routeName: 'api:stations:podcast:episode:art-internal',
routeParams: ['episode_id' => $record->getId()],
absolute: !$isInternal
);
$return->links['media'] = $router->fromHere(
routeName: 'api:stations:podcast:episode:media-internal',
routeParams: ['episode_id' => $record->getId()],
absolute: !$isInternal
);
}
return $return;
}
}

View File

@ -6,7 +6,6 @@ namespace App\Controller\Api\Stations\Podcasts\Art;
use App\Container\EntityManagerAwareTrait;
use App\Controller\SingleActionInterface;
use App\Entity\Api\Error;
use App\Entity\Api\Status;
use App\Entity\Repository\PodcastRepository;
use App\Http\Response;
@ -52,22 +51,7 @@ final class DeleteArtAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
/** @var string $podcastId */
$podcastId = $params['podcast_id'];
$station = $request->getStation();
$podcast = $this->podcastRepo->fetchPodcastForStation($station, $podcastId);
if ($podcast === null) {
return $response->withStatus(404)
->withJson(
new Error(
404,
__('Podcast not found!')
)
);
}
$podcast = $request->getPodcast();
$this->podcastRepo->removePodcastArt($podcast);
$this->em->persist($podcast);

View File

@ -53,12 +53,10 @@ final class GetArtAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
/** @var string $podcastId */
$podcastId = $params['podcast_id'];
$podcast = $request->getPodcast();
$station = $request->getStation();
$podcastPath = Podcast::getArtPath($podcastId);
$podcastPath = Podcast::getArtPath($podcast->getIdRequired());
$fsPodcasts = $this->stationFilesystems->getPodcastsFilesystem($station);

View File

@ -6,9 +6,9 @@ namespace App\Controller\Api\Stations\Podcasts\Art;
use App\Container\EntityManagerAwareTrait;
use App\Controller\SingleActionInterface;
use App\Entity\Api\Error;
use App\Entity\Api\Status;
use App\Entity\Repository\PodcastRepository;
use App\Exception\InvalidRequestAttribute;
use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
@ -53,11 +53,14 @@ final class PostArtAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
/** @var string|null $podcastId */
$podcastId = $params['podcast_id'] ?? null;
$station = $request->getStation();
try {
$podcast = $request->getPodcast();
} catch (InvalidRequestAttribute) {
$podcast = null;
}
$mediaStorage = $station->getPodcastsStorageLocation();
$mediaStorage->errorIfFull();
@ -66,14 +69,7 @@ final class PostArtAction implements SingleActionInterface
return $flowResponse;
}
if (null !== $podcastId) {
$podcast = $this->podcastRepo->fetchPodcastForStation($station, $podcastId);
if (null === $podcast) {
return $response->withStatus(404)
->withJson(Error::notFound());
}
if (null !== $podcast) {
$this->podcastRepo->writePodcastArt(
$podcast,
$flowResponse->readAndDeleteUploadedFile()

View File

@ -12,6 +12,7 @@ use App\Entity\Repository\PodcastEpisodeRepository;
use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Utilities\Types;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
@ -70,12 +71,13 @@ final class DeleteArtAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
/** @var string $episodeId */
$episodeId = $params['episode_id'];
$episodeId = Types::string($params['episode_id'] ?? null);
$station = $request->getStation();
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
$request->getPodcast(),
$episodeId
);
$episode = $this->episodeRepo->fetchEpisodeForStation($station, $episodeId);
if ($episode === null) {
return $response->withStatus(404)
->withJson(Error::notFound());

View File

@ -12,6 +12,7 @@ use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Utilities\Types;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
@ -61,12 +62,9 @@ final class GetArtAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
/** @var string $podcastId */
$podcastId = $params['podcast_id'];
/** @var string $episodeId */
$episodeId = $params['episode_id'];
$episodeId = Types::string($params['episode_id'] ?? null);
$podcast = $request->getPodcast();
$station = $request->getStation();
$episodeArtPath = PodcastEpisode::getArtPath($episodeId);
@ -76,8 +74,7 @@ final class GetArtAction implements SingleActionInterface
return $response->streamFilesystemFile($fsPodcasts, $episodeArtPath, null, 'inline', false);
}
$podcastArtPath = Podcast::getArtPath($podcastId);
$podcastArtPath = Podcast::getArtPath($podcast->getIdRequired());
if ($fsPodcasts->fileExists($podcastArtPath)) {
return $response->streamFilesystemFile($fsPodcasts, $podcastArtPath, null, 'inline', false);
}

View File

@ -13,6 +13,7 @@ use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Service\Flow;
use App\Utilities\Types;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
@ -60,8 +61,7 @@ final class PostArtAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
/** @var string|null $episodeId */
$episodeId = $params['episode_id'] ?? null;
$episodeId = Types::stringOrNull($params['episode_id'] ?? null, true);
$station = $request->getStation();
@ -71,7 +71,10 @@ final class PostArtAction implements SingleActionInterface
}
if (null !== $episodeId) {
$episode = $this->episodeRepo->fetchEpisodeForStation($station, $episodeId);
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
$request->getPodcast(),
$episodeId
);
if (null === $episode) {
return $response->withStatus(404)

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Episodes;
use App\Controller\SingleActionInterface;
use App\Entity\ApiGenerator\PodcastEpisodeApiGenerator;
use App\Entity\Repository\PodcastEpisodeRepository;
use App\Exception\NotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Utilities\Types;
use Psr\Http\Message\ResponseInterface;
final class GetEpisodeAction implements SingleActionInterface
{
public function __construct(
private readonly PodcastEpisodeRepository $episodeRepo,
private readonly PodcastEpisodeApiGenerator $episodeApiGen
) {
}
public function __invoke(ServerRequest $request, Response $response, array $params): ResponseInterface
{
$episodeId = Types::string($params['episode_id'] ?? null);
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
$request->getPodcast(),
$episodeId
);
if (null === $episode) {
throw NotFoundException::podcast();
}
return $response->withJson(
$this->episodeApiGen->__invoke($episode, $request)
);
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Episodes;
use App\Container\EntityManagerAwareTrait;
use App\Controller\Api\Traits\CanSearchResults;
use App\Controller\SingleActionInterface;
use App\Entity\ApiGenerator\PodcastEpisodeApiGenerator;
use App\Entity\PodcastEpisode;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Paginator;
use Psr\Http\Message\ResponseInterface;
final class ListEpisodesAction implements SingleActionInterface
{
use EntityManagerAwareTrait;
use CanSearchResults;
public function __construct(
private readonly PodcastEpisodeApiGenerator $episodeApiGen
) {
}
public function __invoke(ServerRequest $request, Response $response, array $params): ResponseInterface
{
$podcast = $request->getPodcast();
$queryBuilder = $this->em->createQueryBuilder()
->select('e, p, pm')
->from(PodcastEpisode::class, 'e')
->join('e.podcast', 'p')
->leftJoin('e.media', 'pm')
->where('e.podcast = :podcast')
->setParameter('podcast', $podcast)
->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,
[
'e.title',
]
);
$paginator = Paginator::fromQueryBuilder($queryBuilder, $request);
$paginator->setPostprocessor(fn($row) => $this->episodeApiGen->__invoke($row, $request));
return $paginator->write($response);
}
}

View File

@ -13,6 +13,7 @@ use App\Entity\Repository\PodcastEpisodeRepository;
use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Utilities\Types;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
@ -58,11 +59,12 @@ final class DeleteMediaAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
/** @var string $episodeId */
$episodeId = $params['episode_id'];
$episodeId = Types::string($params['episode_id'] ?? null);
$station = $request->getStation();
$episode = $this->episodeRepo->fetchEpisodeForStation($station, $episodeId);
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
$request->getPodcast(),
$episodeId
);
if (!($episode instanceof PodcastEpisode)) {
return $response->withStatus(404)

View File

@ -13,6 +13,7 @@ use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Utilities\Types;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
@ -64,11 +65,13 @@ final class GetMediaAction implements SingleActionInterface
): ResponseInterface {
set_time_limit(600);
/** @var string $episodeId */
$episodeId = $params['episode_id'];
$episodeId = Types::string($params['episode_id'] ?? null);
$station = $request->getStation();
$episode = $this->episodeRepo->fetchEpisodeForStation($station, $episodeId);
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
$request->getPodcast(),
$episodeId
);
if ($episode instanceof PodcastEpisode) {
$podcastMedia = $episode->getMedia();

View File

@ -13,6 +13,7 @@ use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Service\Flow;
use App\Utilities\Types;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
@ -59,8 +60,7 @@ final class PostMediaAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
/** @var string|null $episodeId */
$episodeId = $params['episode_id'] ?? null;
$episodeId = Types::stringOrNull($params['episode_id'] ?? null, true);
$station = $request->getStation();
@ -70,7 +70,10 @@ final class PostMediaAction implements SingleActionInterface
}
if (null !== $episodeId) {
$episode = $this->episodeRepo->fetchEpisodeForStation($station, $episodeId);
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
$request->getPodcast(),
$episodeId
);
if (null === $episode) {
return $response->withStatus(404)

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts;
use App\Controller\SingleActionInterface;
use App\Entity\ApiGenerator\PodcastApiGenerator;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class GetPodcastAction implements SingleActionInterface
{
public function __construct(
private readonly PodcastApiGenerator $podcastApiGen
) {
}
public function __invoke(ServerRequest $request, Response $response, array $params): ResponseInterface
{
$podcast = $request->getPodcast();
return $response->withJson(
$this->podcastApiGen->__invoke($podcast, $request)
);
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts;
use App\Container\EntityManagerAwareTrait;
use App\Controller\Api\Traits\CanSearchResults;
use App\Controller\SingleActionInterface;
use App\Entity\ApiGenerator\PodcastApiGenerator;
use App\Entity\Podcast;
use App\Entity\Repository\PodcastRepository;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Paginator;
use Psr\Http\Message\ResponseInterface;
final class ListPodcastsAction implements SingleActionInterface
{
use EntityManagerAwareTrait;
use CanSearchResults;
public function __construct(
private readonly PodcastApiGenerator $podcastApiGen,
private readonly PodcastRepository $podcastRepo
) {
}
public function __invoke(ServerRequest $request, Response $response, array $params): ResponseInterface
{
$station = $request->getStation();
$queryBuilder = $this->em->createQueryBuilder()
->select('p, pc')
->from(Podcast::class, 'p')
->leftJoin('p.categories', 'pc')
->where('p.storage_location = :storageLocation')
->setParameter('storageLocation', $station->getPodcastsStorageLocation())
->andWhere('p.id IN (:podcastIds)')
->setParameter('podcastIds', $this->podcastRepo->getPodcastIdsWithPublishedEpisodes($station))
->orderBy('p.title', 'ASC');
$queryBuilder = $this->searchQueryBuilder(
$request,
$queryBuilder,
[
'p.title',
]
);
$paginator = Paginator::fromQueryBuilder($queryBuilder, $request);
$paginator->setPostprocessor(fn($row) => $this->podcastApiGen->__invoke($row, $request));
return $paginator->write($response);
}
}

View File

@ -7,22 +7,17 @@ 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\ApiGenerator\PodcastApiGenerator;
use App\Entity\Podcast;
use App\Entity\PodcastCategory;
use App\Entity\Repository\PodcastRepository;
use App\Enums\StationPermissions;
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;
@ -160,6 +155,7 @@ final class PodcastsController extends AbstractApiCrudController
public function __construct(
private readonly PodcastRepository $podcastRepository,
private readonly PodcastApiGenerator $podcastApiGen,
Serializer $serializer,
ValidatorInterface $validator,
) {
@ -238,113 +234,56 @@ final class PodcastsController extends AbstractApiCrudController
throw new InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass));
}
$isInternal = Types::bool($request->getParam('internal'), false, true);
$isInternal = $request->isInternal();
$router = $request->getRouter();
$station = $request->getStation();
$return = new ApiPodcast();
$return->id = $record->getIdRequired();
$return->storage_location_id = $record->getStorageLocation()->getIdRequired();
$return = $this->podcastApiGen->__invoke($record, $request);
$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) {
$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;
$episodes = [];
foreach ($record->getEpisodes() as $episode) {
$episodes[] = $episode->getId();
}
$return->episodes = $episodes;
$return->has_custom_art = (0 !== $record->getArtUpdatedAt());
$routeParams = [
'podcast_id' => $record->getId(),
$baseRouteParams = [
'station_id' => $request->getStation()->getIdRequired(),
'podcast_id' => $record->getIdRequired(),
];
if ($return->has_custom_art) {
$routeParams['timestamp'] = $record->getArtUpdatedAt();
$artRouteParams = $baseRouteParams;
if (0 !== $return->art_updated_at) {
$artRouteParams['timestamp'] = $return->art_updated_at;
}
$return->art = $router->fromHere(
$return->art = $router->named(
routeName: 'api:stations:podcast:art',
routeParams: $routeParams,
absolute: true
routeParams: $artRouteParams,
absolute: !$isInternal
);
$return->links = [
'self' => $router->fromHere(
...$return->links,
'self' => $router->named(
routeName: $this->resourceRouteName,
routeParams: ['podcast_id' => $record->getId()],
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'episodes' => $router->fromHere(
'art' => $router->named(
routeName: 'api:stations:podcast:art',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'episodes' => $router->named(
routeName: 'api:stations:podcast:episodes',
routeParams: ['podcast_id' => $record->getId()],
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'public_episodes' => $router->fromHere(
routeName: 'public:podcast',
routeParams: ['podcast_id' => $record->getId()],
'episode_new_art' => $router->named(
routeName: 'api:stations:podcast:episodes:new-art',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'public_feed' => $router->fromHere(
routeName: 'public:podcast:feed',
routeParams: ['podcast_id' => $record->getId()],
'episode_new_media' => $router->named(
routeName: 'api:stations:podcast:episodes:new-media',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
];
$acl = $request->getAcl();
if ($acl->isAllowed(StationPermissions::Podcasts, $station)) {
$return->links['art'] = $router->fromHere(
routeName: 'api:stations:podcast:art-internal',
routeParams: ['podcast_id' => $record->getId()],
absolute: !$isInternal
);
$return->links['episode_new_art'] = $router->fromHere(
routeName: 'api:stations:podcast:episodes:new-art',
routeParams: ['podcast_id' => $record->getId()],
absolute: !$isInternal
);
$return->links['episode_new_media'] = $router->fromHere(
routeName: 'api:stations:podcast:episodes:new-media',
routeParams: ['podcast_id' => $record->getId()],
absolute: !$isInternal
);
}
return $return;
}

View File

@ -62,10 +62,4 @@ final class Podcast
items: new OA\Items(type: PodcastCategory::class)
)]
public array $categories = [];
#[OA\Property(
type: 'array',
items: new OA\Items(type: 'string')
)]
public array $episodes = [];
}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Entity\ApiGenerator;
use App\Entity\Api\Podcast as ApiPodcast;
use App\Entity\Api\PodcastCategory as ApiPodcastCategory;
use App\Entity\Podcast;
use App\Http\ServerRequest;
use App\Utilities\Strings;
use Symfony\Component\Intl\Exception\MissingResourceException;
use Symfony\Component\Intl\Languages;
final class PodcastApiGenerator
{
public function __invoke(
Podcast $record,
ServerRequest $request
): ApiPodcast {
$router = $request->getRouter();
$isInternal = $request->isInternal();
$station = $request->getStation();
$return = new ApiPodcast();
$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) {
$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;
$return->art_updated_at = $record->getArtUpdatedAt();
$return->has_custom_art = (0 !== $record->getArtUpdatedAt());
$baseRouteParams = [
'station_id' => $station->getIdRequired(),
'podcast_id' => $record->getIdRequired(),
];
$artRouteParams = $baseRouteParams;
if ($return->has_custom_art) {
$artRouteParams['timestamp'] = $record->getArtUpdatedAt();
}
$return->art = $router->named(
routeName: 'api:stations:public:podcast:art',
routeParams: $artRouteParams,
absolute: !$isInternal
);
$return->links = [
'self' => $router->named(
routeName: 'api:stations:public:podcast',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'episodes' => $router->named(
routeName: 'api:stations:public:podcast:episodes',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'public_episodes' => $router->named(
routeName: 'public:podcast',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'public_feed' => $router->named(
routeName: 'public:podcast:feed',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
];
return $return;
}
}

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Entity\ApiGenerator;
use App\Entity\Api\PodcastEpisode as ApiPodcastEpisode;
use App\Entity\Api\PodcastMedia as ApiPodcastMedia;
use App\Entity\PodcastEpisode;
use App\Entity\PodcastMedia;
use App\Http\ServerRequest;
use App\Utilities\Strings;
final class PodcastEpisodeApiGenerator
{
public function __invoke(
PodcastEpisode $record,
ServerRequest $request
): ApiPodcastEpisode {
$router = $request->getRouter();
$isInternal = $request->isInternal();
$station = $request->getStation();
$podcast = $request->getPodcast();
$return = new ApiPodcastEpisode();
$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();
$return->has_media = ($mediaRow instanceof PodcastMedia);
if ($mediaRow instanceof PodcastMedia) {
$media = new ApiPodcastMedia();
$media->id = $mediaRow->getId();
$media->original_name = $mediaRow->getOriginalName();
$media->length = $mediaRow->getLength();
$media->length_text = $mediaRow->getLengthText();
$media->path = $mediaRow->getPath();
$return->has_media = true;
$return->media = $media;
} else {
$return->has_media = false;
$return->media = new ApiPodcastMedia();
}
$return->art_updated_at = $record->getArtUpdatedAt();
$return->has_custom_art = (0 !== $return->art_updated_at);
$baseRouteParams = [
'station_id' => $station->getShortName(),
'podcast_id' => $podcast->getIdRequired(),
'episode_id' => $record->getIdRequired(),
];
$artRouteParams = $baseRouteParams;
if (0 !== $return->art_updated_at) {
$artRouteParams['timestamp'] = $return->art_updated_at;
}
$return->art = $router->named(
routeName: 'api:stations:public:podcast:episode:art',
routeParams: $artRouteParams,
absolute: !$isInternal
);
$return->links = [
'self' => $router->named(
routeName: 'api:stations:public:podcast:episode',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'public' => $router->fromHere(
routeName: 'public:podcast:episode',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
'download' => $router->fromHere(
routeName: 'api:stations:public:podcast:episode:download',
routeParams: $baseRouteParams,
absolute: !$isInternal
),
];
return $return;
}
}

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Entity\Repository;
use App\Doctrine\Repository;
use App\Entity\Podcast;
use App\Entity\PodcastEpisode;
use App\Entity\PodcastMedia;
use App\Entity\Station;
@ -30,6 +31,14 @@ final class PodcastEpisodeRepository extends Repository
) {
}
public function fetchEpisodeForPodcast(Podcast $podcast, string $episodeId): ?PodcastEpisode
{
return $this->repository->findOneBy([
'id' => $episodeId,
'podcast' => $podcast,
]);
}
public function fetchEpisodeForStation(Station $station, string $episodeId): ?PodcastEpisode
{
return $this->fetchEpisodeForStorageLocation(

View File

@ -7,7 +7,6 @@ namespace App\Entity\Repository;
use App\Doctrine\Repository;
use App\Entity\Podcast;
use App\Entity\Station;
use App\Entity\StorageLocation;
use App\Exception\StorageLocationFullException;
use App\Flysystem\ExtendedFilesystemInterface;
use App\Media\AlbumArt;
@ -29,21 +28,34 @@ final class PodcastRepository extends Repository
public function fetchPodcastForStation(Station $station, string $podcastId): ?Podcast
{
return $this->fetchPodcastForStorageLocation($station->getPodcastsStorageLocation(), $podcastId);
}
public function fetchPodcastForStorageLocation(
StorageLocation $storageLocation,
string $podcastId
): ?Podcast {
return $this->repository->findOneBy(
[
'id' => $podcastId,
'storage_location' => $storageLocation,
'storage_location' => $station->getPodcastsStorageLocation(),
]
);
}
/**
* @param Station $station
* @return string[]
*/
public function getPodcastIdsWithPublishedEpisodes(Station $station): array
{
return $this->em->createQuery(
<<<'DQL'
SELECT DISTINCT p.id
FROM App\Entity\PodcastEpisode pe
JOIN pe.podcast p
JOIN pe.media pm
WHERE pm.id IS NOT NULL
AND (pe.publish_at IS NULL OR pe.publish_at <= :time)
DQL
)->setParameter('time', time())
->enableResultCache(60, 'podcast_ids_' . $station->getIdRequired())
->getSingleColumnResult();
}
public function writePodcastArt(
Podcast $podcast,
string $rawArtworkString,

View File

@ -12,7 +12,7 @@ final class NotFoundException extends Exception
{
public function __construct(
string $message = 'Record not found.',
int $code = 0,
int $code = 404,
Throwable $previous = null,
Level $loggerLevel = Level::Debug
) {

View File

@ -7,6 +7,7 @@ namespace App\Http;
use App\Acl;
use App\Auth;
use App\Customization;
use App\Entity\Podcast;
use App\Entity\Station;
use App\Entity\User;
use App\Enums\SupportedLocales;
@ -31,6 +32,7 @@ final class ServerRequest extends SlimServerRequest
public const ATTR_CUSTOMIZATION = 'customization';
public const ATTR_AUTH = 'auth';
public const ATTR_STATION = 'station';
public const ATTR_PODCAST = 'podcast';
public const ATTR_USER = 'user';
/**
@ -129,6 +131,14 @@ final class ServerRequest extends SlimServerRequest
return $this->getAttributeOfClass(self::ATTR_STATION, Station::class);
}
/**
* @throws InvalidRequestAttribute
*/
public function getPodcast(): Podcast
{
return $this->getAttributeOfClass(self::ATTR_PODCAST, Podcast::class);
}
/**
* @template T of object
*

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Entity\Podcast;
use App\Entity\Repository\PodcastRepository;
use App\Exception\NotFoundException;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Routing\RouteContext;
/**
* Retrieve the podcast specified in the request parameters.
*/
final class GetAndRequirePodcast extends AbstractMiddleware
{
public function __construct(
private readonly PodcastRepository $podcastRepo
) {
}
public function __invoke(ServerRequest $request, RequestHandlerInterface $handler): ResponseInterface
{
$routeArgs = RouteContext::fromRequest($request)->getRoute()?->getArguments();
$id = $routeArgs['podcast_id'] ?? $routeArgs['id'] ?? null;
if (empty($id)) {
throw NotFoundException::podcast();
}
$record = $this->podcastRepo->fetchPodcastForStation(
$request->getStation(),
$id
);
if (!($record instanceof Podcast)) {
throw NotFoundException::podcast();
}
$request = $request->withAttribute(ServerRequest::ATTR_PODCAST, $record);
return $handler->handle($request);
}
}

View File

@ -4,18 +4,11 @@ declare(strict_types=1);
namespace App\Middleware;
use App\Acl;
use App\Entity\PodcastEpisode;
use App\Entity\Repository\PodcastRepository;
use App\Entity\Station;
use App\Entity\User;
use App\Enums\StationPermissions;
use App\Exception\NotFoundException;
use App\Http\ServerRequest;
use Exception;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Routing\RouteContext;
/**
* Require that the podcast has a published episode for public access
@ -29,70 +22,15 @@ final class RequirePublishedPodcastEpisodeMiddleware extends AbstractMiddleware
public function __invoke(ServerRequest $request, RequestHandlerInterface $handler): ResponseInterface
{
$user = $this->getLoggedInUser($request);
$station = $request->getStation();
if ($user !== null) {
$acl = $request->getAcl();
$publishedPodcastIds = $this->podcastRepository->getPodcastIdsWithPublishedEpisodes($station);
if ($this->canUserManageStationPodcasts($user, $station, $acl)) {
return $handler->handle($request);
}
}
$podcastId = $this->getPodcastIdFromRequest($request);
if ($podcastId === null || !$this->checkPodcastHasPublishedEpisodes($station, $podcastId)) {
$podcast = $request->getPodcast();
if (!in_array($podcast->getIdRequired(), $publishedPodcastIds, true)) {
throw NotFoundException::podcast();
}
return $handler->handle($request);
}
private function getLoggedInUser(ServerRequest $request): ?User
{
try {
return $request->getUser();
} catch (Exception) {
return null;
}
}
private function canUserManageStationPodcasts(User $user, Station $station, Acl $acl): bool
{
return $acl->userAllowed($user, StationPermissions::Podcasts, $station->getId());
}
private function getPodcastIdFromRequest(ServerRequest $request): ?string
{
$routeArgs = RouteContext::fromRequest($request)->getRoute()?->getArguments();
$podcastId = $routeArgs['id'] ?? null;
if ($podcastId === null) {
$podcastId = $routeArgs['podcast_id'] ?? null;
}
return $podcastId;
}
private function checkPodcastHasPublishedEpisodes(Station $station, string $podcastId): bool
{
$podcastId = explode('|', $podcastId, 2)[0];
$podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcastId);
if ($podcast === null) {
return false;
}
/** @var PodcastEpisode $episode */
foreach ($podcast->getEpisodes() as $episode) {
if ($episode->isPublished()) {
return true;
}
}
return false;
}
}