-
-
diff --git a/frontend/vue/Stations/Podcasts/Common/Artwork.vue b/frontend/vue/Stations/Podcasts/Common/Artwork.vue
new file mode 100644
index 000000000..c4c828313
--- /dev/null
+++ b/frontend/vue/Stations/Podcasts/Common/Artwork.vue
@@ -0,0 +1,54 @@
+
+
+
diff --git a/frontend/vue/Stations/Podcasts/EpisodeEditModal.vue b/frontend/vue/Stations/Podcasts/EpisodeEditModal.vue
new file mode 100644
index 000000000..f635ffb12
--- /dev/null
+++ b/frontend/vue/Stations/Podcasts/EpisodeEditModal.vue
@@ -0,0 +1,236 @@
+
+
+
diff --git a/frontend/vue/Stations/Podcasts/EpisodeForm/BasicInfo.vue b/frontend/vue/Stations/Podcasts/EpisodeForm/BasicInfo.vue
new file mode 100644
index 000000000..9b0481b9a
--- /dev/null
+++ b/frontend/vue/Stations/Podcasts/EpisodeForm/BasicInfo.vue
@@ -0,0 +1,86 @@
+
+
+
diff --git a/frontend/vue/Stations/Podcasts/EpisodeForm/Media.vue b/frontend/vue/Stations/Podcasts/EpisodeForm/Media.vue
new file mode 100644
index 000000000..4d951be94
--- /dev/null
+++ b/frontend/vue/Stations/Podcasts/EpisodeForm/Media.vue
@@ -0,0 +1,56 @@
+
+
+
diff --git a/frontend/vue/Stations/Podcasts/EpisodesView.vue b/frontend/vue/Stations/Podcasts/EpisodesView.vue
new file mode 100644
index 000000000..150afa5cb
--- /dev/null
+++ b/frontend/vue/Stations/Podcasts/EpisodesView.vue
@@ -0,0 +1,158 @@
+
+
+
diff --git a/frontend/vue/Stations/Podcasts/ListView.vue b/frontend/vue/Stations/Podcasts/ListView.vue
new file mode 100644
index 000000000..3e6c93453
--- /dev/null
+++ b/frontend/vue/Stations/Podcasts/ListView.vue
@@ -0,0 +1,154 @@
+
+
+
diff --git a/frontend/vue/Stations/Podcasts/PodcastEditModal.vue b/frontend/vue/Stations/Podcasts/PodcastEditModal.vue
new file mode 100644
index 000000000..54a965ec9
--- /dev/null
+++ b/frontend/vue/Stations/Podcasts/PodcastEditModal.vue
@@ -0,0 +1,220 @@
+
+
+
diff --git a/frontend/vue/Stations/Podcasts/PodcastForm/BasicInfo.vue b/frontend/vue/Stations/Podcasts/PodcastForm/BasicInfo.vue
new file mode 100644
index 000000000..c29b49564
--- /dev/null
+++ b/frontend/vue/Stations/Podcasts/PodcastForm/BasicInfo.vue
@@ -0,0 +1,84 @@
+
+
+
diff --git a/frontend/vue/Stations/Profile/PublicPagesPanel.vue b/frontend/vue/Stations/Profile/PublicPagesPanel.vue
index fe1c9e392..3c81ad12f 100644
--- a/frontend/vue/Stations/Profile/PublicPagesPanel.vue
+++ b/frontend/vue/Stations/Profile/PublicPagesPanel.vue
@@ -31,6 +31,12 @@
@@ -78,6 +84,7 @@ export const profilePublicProps = {
publicPageUri: String,
publicWebDjUri: String,
publicOnDemandUri: String,
+ publicPodcastsUri: String,
togglePublicPageUri: String
}
};
diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js
index b510cce63..1a909f9ba 100644
--- a/frontend/webpack.config.js
+++ b/frontend/webpack.config.js
@@ -16,6 +16,7 @@ module.exports = {
PublicWebDJ: './vue/Public/WebDJ.vue',
StationsMedia: './vue/Stations/Media.vue',
StationsPlaylists: './vue/Stations/Playlists.vue',
+ StationsPodcasts: './vue/Stations/Podcasts.vue',
StationsProfile: './vue/Stations/Profile.vue',
StationsQueue: './vue/Stations/Queue.vue',
StationsStreamers: './vue/Stations/Streamers.vue',
diff --git a/src/Acl.php b/src/Acl.php
index cf573a364..fdfc5519f 100644
--- a/src/Acl.php
+++ b/src/Acl.php
@@ -31,6 +31,7 @@ class Acl
public const STATION_MEDIA = 'manage station media';
public const STATION_AUTOMATION = 'manage station automation';
public const STATION_WEB_HOOKS = 'manage station web hooks';
+ public const STATION_PODCASTS = 'manage station podcasts';
protected array $permissions;
@@ -103,6 +104,7 @@ class Acl
self::STATION_MEDIA => __('Manage Station Media'),
self::STATION_AUTOMATION => __('Manage Station Automation'),
self::STATION_WEB_HOOKS => __('Manage Station Web Hooks'),
+ self::STATION_PODCASTS => __('Manage Station Podcasts'),
],
];
diff --git a/src/Controller/Api/Stations/PodcastEpisodesController.php b/src/Controller/Api/Stations/PodcastEpisodesController.php
new file mode 100644
index 000000000..d8277e657
--- /dev/null
+++ b/src/Controller/Api/Stations/PodcastEpisodesController.php
@@ -0,0 +1,386 @@
+getStation();
+
+ $podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcast_id);
+
+ $queryBuilder = $this->em->createQueryBuilder()
+ ->select('e, p, pm')
+ ->from(Entity\PodcastEpisode::class, 'e')
+ ->join('e.podcast', 'p')
+ ->leftJoin('e.media', 'pm')
+ ->where('e.podcast = :podcast')
+ ->orderBy('e.title', 'ASC')
+ ->setParameter('podcast', $podcast);
+
+ $searchPhrase = trim($request->getParam('searchPhrase', ''));
+ if (!empty($searchPhrase)) {
+ $queryBuilder->andWhere('e.title LIKE :title')
+ ->setParameter('title', '%' . $searchPhrase . '%');
+ }
+
+ return $this->listPaginatedFromQuery($request, $response, $queryBuilder->getQuery());
+ }
+
+ public function getAction(
+ ServerRequest $request,
+ Response $response,
+ string $episode_id
+ ): ResponseInterface {
+ $station = $request->getStation();
+ $record = $this->getRecord($station, $episode_id);
+
+ if (null === $record) {
+ return $response->withStatus(404)
+ ->withJson(new Entity\Api\Error(404, __('Record not found!')));
+ }
+
+ $return = $this->viewRecord($record, $request);
+ return $response->withJson($return);
+ }
+
+ public function createAction(
+ ServerRequest $request,
+ Response $response,
+ string $podcast_id
+ ): ResponseInterface {
+ $station = $request->getStation();
+
+ $podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcast_id);
+ $record = $this->editRecord(
+ $request->getParsedBody(),
+ new Entity\PodcastEpisode($podcast)
+ );
+
+ $this->processFiles($request, $record);
+
+ return $response->withJson($this->viewRecord($record, $request));
+ }
+
+ public function editAction(
+ ServerRequest $request,
+ Response $response,
+ string $episode_id
+ ): ResponseInterface {
+ $podcast = $this->getRecord($request->getStation(), $episode_id);
+
+ if ($podcast === null) {
+ return $response->withStatus(404)
+ ->withJson(new Entity\Api\Error(404, __('Record not found!')));
+ }
+
+ $this->editRecord($request->getParsedBody(), $podcast);
+ $this->processFiles($request, $podcast);
+
+ return $response->withJson(new Entity\Api\Status(true, __('Changes saved successfully.')));
+ }
+
+ public function deleteAction(
+ ServerRequest $request,
+ Response $response,
+ string $episode_id
+ ): ResponseInterface {
+ $station = $request->getStation();
+ $record = $this->getRecord($station, $episode_id);
+
+ if (null === $record) {
+ return $response->withStatus(404)
+ ->withJson(new Entity\Api\Error(404, __('Record not found!')));
+ }
+
+ $fsStation = new StationFilesystems($station);
+ $this->episodeRepository->delete($record, $fsStation->getPodcastsFilesystem());
+
+ return $response->withJson(new Entity\Api\Status(true, __('Record deleted successfully.')));
+ }
+
+ /**
+ * @param Entity\Station $station
+ * @param string $id
+ */
+ protected function getRecord(Entity\Station $station, string $id): ?object
+ {
+ return $this->episodeRepository->fetchEpisodeForStation($station, $id);
+ }
+
+ protected function viewRecord(object $record, ServerRequest $request): mixed
+ {
+ if (!($record instanceof Entity\PodcastEpisode)) {
+ throw new \InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass));
+ }
+
+ $isInternal = ('true' === $request->getParam('internal', 'false'));
+ $router = $request->getRouter();
+
+ $return = new Entity\Api\PodcastEpisode();
+ $return->id = $record->getId();
+ $return->title = $record->getTitle();
+ $return->description = $record->getDescription();
+ $return->explicit = $record->getExplicit();
+ $return->publish_at = $record->getPublishAt();
+
+ $mediaRow = $record->getMedia();
+ $return->has_media = ($mediaRow instanceof Entity\PodcastMedia);
+ if ($mediaRow instanceof Entity\PodcastMedia) {
+ $media = new Entity\Api\PodcastMedia();
+ $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 Entity\Api\PodcastMedia();
+ }
+
+ $return->art_updated_at = $record->getArtUpdatedAt();
+ $return->has_custom_art = (0 !== $return->art_updated_at);
+
+ $return->art = $router->fromHere(
+ route_name: 'api:stations:podcast:episode:art',
+ route_params: ['episode_id' => $record->getId() . '|' . $record->getArtUpdatedAt()],
+ absolute: true
+ );
+
+ $return->links = [
+ 'self' => $router->fromHere(
+ route_name: $this->resourceRouteName,
+ route_params: ['episode_id' => $record->getId()],
+ absolute: !$isInternal
+ ),
+ 'public' => $router->fromHere(
+ route_name: 'public:podcast:episode',
+ route_params: ['episode_id' => $record->getId()],
+ absolute: !$isInternal
+ ),
+ 'download' => $router->fromHere(
+ route_name: 'api:stations:podcast:episode:download',
+ route_params: ['episode_id' => $record->getId()],
+ absolute: !$isInternal
+ ),
+ ];
+
+ $acl = $request->getAcl();
+ $station = $request->getStation();
+
+ if ($acl->isAllowed(Acl::STATION_PODCASTS, $station)) {
+ $return->links['art'] = $router->fromHere(
+ route_name: 'api:stations:podcast:episode:art-internal',
+ route_params: ['episode_id' => $record->getId()],
+ absolute: !$isInternal
+ );
+ }
+
+ return $return;
+ }
+
+ protected function processFiles(
+ ServerRequest $request,
+ Entity\PodcastEpisode $record
+ ): void {
+ $files = $request->getUploadedFiles();
+
+ $artwork = $files['artwork_file'] ?? null;
+ if ($artwork instanceof UploadedFileInterface && UPLOAD_ERR_OK === $artwork->getError()) {
+ $this->episodeRepository->writeEpisodeArt(
+ $record,
+ $artwork->getStream()->getContents()
+ );
+
+ $this->em->persist($record);
+ $this->em->flush();
+ }
+
+ $media = $files['media_file'] ?? null;
+ if ($media instanceof UploadedFileInterface && UPLOAD_ERR_OK === $media->getError()) {
+ $fsStations = new StationFilesystems($request->getStation());
+ $fsTemp = $fsStations->getTempFilesystem();
+
+ $originalName = basename($media->getClientFilename()) ?? $record->getId() . '.mp3';
+ $originalExt = pathinfo($originalName, PATHINFO_EXTENSION);
+
+ $tempPath = $fsTemp->getLocalPath($record->getId() . '.' . $originalExt);
+ $media->moveTo($tempPath);
+
+ $artwork = $this->podcastMediaRepository->upload(
+ $record,
+ $originalName,
+ $tempPath,
+ $fsStations->getPodcastsFilesystem()
+ );
+
+ if (!empty($artwork) && 0 === $record->getArtUpdatedAt()) {
+ $this->episodeRepository->writeEpisodeArt(
+ $record,
+ $artwork
+ );
+ }
+
+ $this->em->persist($record);
+ $this->em->flush();
+ }
+ }
+}
diff --git a/src/Controller/Api/Stations/Podcasts/Art/DeleteArtAction.php b/src/Controller/Api/Stations/Podcasts/Art/DeleteArtAction.php
new file mode 100644
index 000000000..8ed200d7c
--- /dev/null
+++ b/src/Controller/Api/Stations/Podcasts/Art/DeleteArtAction.php
@@ -0,0 +1,47 @@
+getStation();
+
+ $podcast = $podcastRepo->fetchPodcastForStation($station, $podcast_id);
+
+ if ($podcast === null) {
+ return $response->withStatus(404)
+ ->withJson(
+ new Entity\Api\Error(
+ 404,
+ __('Podcast not found!')
+ )
+ );
+ }
+
+ $podcastRepo->removePodcastArt($podcast);
+ $em->persist($podcast);
+ $em->flush();
+
+ return $response->withJson(
+ new Entity\Api\Status(
+ true,
+ __('Podcast artwork successfully cleared.')
+ )
+ );
+ }
+}
diff --git a/src/Controller/Api/Stations/Podcasts/Art/GetArtAction.php b/src/Controller/Api/Stations/Podcasts/Art/GetArtAction.php
new file mode 100644
index 000000000..0fc428e26
--- /dev/null
+++ b/src/Controller/Api/Stations/Podcasts/Art/GetArtAction.php
@@ -0,0 +1,42 @@
+getStation();
+
+ // If a timestamp delimiter is added, strip it automatically.
+ $podcast_id = explode('|', $podcast_id)[0];
+
+ $podcastPath = Entity\Podcast::getArtPath($podcast_id);
+
+ $fsStation = new StationFilesystems($station);
+ $fsPodcasts = $fsStation->getPodcastsFilesystem();
+
+ if ($fsPodcasts->fileExists($podcastPath)) {
+ return $response->withCacheLifetime(Response::CACHE_ONE_YEAR)
+ ->streamFilesystemFile($fsPodcasts, $podcastPath, null, 'inline');
+ }
+
+ return $response->withRedirect(
+ (string)$stationRepo->getDefaultAlbumArtUrl($station),
+ 302
+ );
+ }
+}
diff --git a/src/Controller/Api/Stations/Podcasts/Episodes/Art/DeleteArtAction.php b/src/Controller/Api/Stations/Podcasts/Episodes/Art/DeleteArtAction.php
new file mode 100644
index 000000000..8263f0f1c
--- /dev/null
+++ b/src/Controller/Api/Stations/Podcasts/Episodes/Art/DeleteArtAction.php
@@ -0,0 +1,36 @@
+getStation();
+
+ $episode = $episodeRepo->fetchEpisodeForStation($station, $episode_id);
+ if ($episode === null) {
+ return $response->withStatus(404)
+ ->withJson(new Entity\Api\Error(404, __('Episode not found!')));
+ }
+
+ $episodeRepo->removeEpisodeArt($episode);
+ $em->persist($episode);
+ $em->flush();
+
+ return $response->withJson(new Entity\Api\Status(true, __('Episode artwork successfully cleared.')));
+ }
+}
diff --git a/src/Controller/Api/Stations/Podcasts/Episodes/Art/GetArtAction.php b/src/Controller/Api/Stations/Podcasts/Episodes/Art/GetArtAction.php
new file mode 100644
index 000000000..6d088ed86
--- /dev/null
+++ b/src/Controller/Api/Stations/Podcasts/Episodes/Art/GetArtAction.php
@@ -0,0 +1,49 @@
+getStation();
+
+ // If a timestamp delimiter is added, strip it automatically.
+ $episode_id = explode('|', $episode_id)[0];
+
+ $episodeArtPath = Entity\PodcastEpisode::getArtPath($episode_id);
+
+ $fsStation = new StationFilesystems($station);
+ $fsPodcasts = $fsStation->getPodcastsFilesystem();
+
+ if ($fsPodcasts->fileExists($episodeArtPath)) {
+ return $response->withCacheLifetime(Response::CACHE_ONE_YEAR)
+ ->streamFilesystemFile($fsPodcasts, $episodeArtPath, null, 'inline');
+ }
+
+ $podcastArtPath = Entity\Podcast::getArtPath($podcast_id);
+
+ if ($fsPodcasts->fileExists($podcastArtPath)) {
+ return $response->withCacheLifetime(Response::CACHE_ONE_DAY)
+ ->streamFilesystemFile($fsPodcasts, $podcastArtPath, null, 'inline');
+ }
+
+ return $response->withRedirect(
+ (string)$stationRepo->getDefaultAlbumArtUrl($station),
+ 302
+ );
+ }
+}
diff --git a/src/Controller/Api/Stations/Podcasts/Episodes/DownloadAction.php b/src/Controller/Api/Stations/Podcasts/Episodes/DownloadAction.php
new file mode 100644
index 000000000..ac1935164
--- /dev/null
+++ b/src/Controller/Api/Stations/Podcasts/Episodes/DownloadAction.php
@@ -0,0 +1,51 @@
+getStation();
+ $episode = $episodeRepo->fetchEpisodeForStation($station, $episode_id);
+
+ if ($episode instanceof Entity\PodcastEpisode) {
+ $podcastMedia = $episode->getMedia();
+
+ if ($podcastMedia instanceof Entity\PodcastMedia) {
+ $fsStation = new StationFilesystems($station);
+ $fsPodcasts = $fsStation->getPodcastsFilesystem();
+
+ $path = $podcastMedia->getPath();
+
+ if ($fsPodcasts->fileExists($path)) {
+ $fileMeta = $fsPodcasts->getMetadata($path);
+ $filename = $podcastMedia->getOriginalName() . '.' . $fileMeta['extension'];
+
+ return $response->streamFilesystemFile(
+ $fsPodcasts,
+ $path,
+ $filename
+ );
+ }
+ }
+ }
+
+ return $response->withStatus(404)
+ ->withJson(new Entity\Api\Error(404, 'Media file not found.'));
+ }
+}
diff --git a/src/Controller/Api/Stations/PodcastsController.php b/src/Controller/Api/Stations/PodcastsController.php
new file mode 100644
index 000000000..7fc2c23fe
--- /dev/null
+++ b/src/Controller/Api/Stations/PodcastsController.php
@@ -0,0 +1,345 @@
+getStation();
+
+ $queryBuilder = $this->em->createQueryBuilder()
+ ->select('p, pc')
+ ->from(Entity\Podcast::class, 'p')
+ ->leftJoin('p.categories', 'pc')
+ ->where('p.storage_location = :storageLocation')
+ ->orderBy('p.title', 'ASC')
+ ->setParameter('storageLocation', $station->getPodcastsStorageLocation());
+
+ $searchPhrase = trim($request->getParam('searchPhrase', ''));
+ if (!empty($searchPhrase)) {
+ $queryBuilder->andWhere('p.title LIKE :title')
+ ->setParameter('title', '%' . $searchPhrase . '%');
+ }
+
+ return $this->listPaginatedFromQuery($request, $response, $queryBuilder->getQuery());
+ }
+
+ public function getAction(
+ ServerRequest $request,
+ Response $response,
+ string $podcast_id
+ ): ResponseInterface {
+ $station = $request->getStation();
+ $record = $this->getRecord($station, $podcast_id);
+
+ if (null === $record) {
+ return $response->withStatus(404)
+ ->withJson(new Entity\Api\Error(404, __('Record not found!')));
+ }
+
+ $return = $this->viewRecord($record, $request);
+ return $response->withJson($return);
+ }
+
+ public function createAction(ServerRequest $request, Response $response): ResponseInterface
+ {
+ $station = $request->getStation();
+
+ $record = $this->editRecord(
+ $request->getParsedBody(),
+ new Entity\Podcast($station->getPodcastsStorageLocation())
+ );
+
+ $this->processFiles($request, $record);
+
+ return $response->withJson($this->viewRecord($record, $request));
+ }
+
+ public function editAction(
+ ServerRequest $request,
+ Response $response,
+ string $podcast_id
+ ): ResponseInterface {
+ $podcast = $this->getRecord($request->getStation(), $podcast_id);
+
+ if ($podcast === null) {
+ return $response->withStatus(404)
+ ->withJson(new Entity\Api\Error(404, __('Record not found!')));
+ }
+
+ $this->editRecord($request->getParsedBody(), $podcast);
+ $this->processFiles($request, $podcast);
+
+ return $response->withJson(new Entity\Api\Status(true, __('Changes saved successfully.')));
+ }
+
+ public function deleteAction(
+ ServerRequest $request,
+ Response $response,
+ string $podcast_id
+ ): ResponseInterface {
+ $station = $request->getStation();
+ $record = $this->getRecord($station, $podcast_id);
+
+ if (null === $record) {
+ return $response->withStatus(404)
+ ->withJson(new Entity\Api\Error(404, __('Record not found!')));
+ }
+
+ $fsStation = new StationFilesystems($station);
+ $this->podcastRepository->delete($record, $fsStation->getPodcastsFilesystem());
+
+ return $response->withJson(new Entity\Api\Status(true, __('Record deleted successfully.')));
+ }
+
+ /**
+ * @param Entity\Station $station
+ * @param string $id
+ */
+ protected function getRecord(Entity\Station $station, string $id): ?object
+ {
+ return $this->podcastRepository->fetchPodcastForStation($station, $id);
+ }
+
+ protected function viewRecord(object $record, ServerRequest $request): mixed
+ {
+ if (!($record instanceof Entity\Podcast)) {
+ throw new \InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass));
+ }
+
+ $isInternal = ('true' === $request->getParam('internal', 'false'));
+ $router = $request->getRouter();
+ $station = $request->getStation();
+
+ $return = new Entity\Api\Podcast();
+ $return->id = $record->getId();
+ $return->storage_location_id = $record->getStorageLocation()?->getId();
+ $return->title = $record->getTitle();
+ $return->link = $record->getLink();
+ $return->description = $record->getDescription();
+ $return->language = $record->getLanguage();
+
+ $categories = [];
+ foreach ($record->getCategories() as $category) {
+ $categories[] = $category->getCategory();
+ }
+ $return->categories = $categories;
+
+ $episodes = [];
+ foreach ($record->getEpisodes() as $episode) {
+ $episodes[] = $episode->getId();
+ }
+ $return->episodes = $episodes;
+
+ $return->has_custom_art = (0 !== $record->getArtUpdatedAt());
+ $return->art = $router->fromHere(
+ route_name: 'api:stations:podcast:art',
+ route_params: ['podcast_id' => $record->getId() . '|' . $record->getArtUpdatedAt()],
+ absolute: true
+ );
+
+ $return->links = [
+ 'self' => $router->fromHere(
+ route_name: $this->resourceRouteName,
+ route_params: ['podcast_id' => $record->getId()],
+ absolute: !$isInternal
+ ),
+ 'episodes' => $router->fromHere(
+ route_name: 'api:stations:podcast:episodes',
+ route_params: ['podcast_id' => $record->getId()],
+ absolute: !$isInternal
+ ),
+ 'public_episodes' => $router->fromHere(
+ route_name: 'public:podcast:episodes',
+ route_params: ['podcast_id' => $record->getId()],
+ absolute: !$isInternal
+ ),
+ 'public_feed' => $router->fromHere(
+ route_name: 'public:podcast:feed',
+ route_params: ['podcast_id' => $record->getId()],
+ absolute: !$isInternal
+ ),
+ ];
+
+ $acl = $request->getAcl();
+
+ if ($acl->isAllowed(Acl::STATION_PODCASTS, $station)) {
+ $return->links['art'] = $router->fromHere(
+ route_name: 'api:stations:podcast:art-internal',
+ route_params: ['podcast_id' => $record->getId()],
+ absolute: !$isInternal
+ );
+ }
+
+ return $return;
+ }
+
+ protected function fromArray($data, $record = null, array $context = []): object
+ {
+ return parent::fromArray(
+ $data,
+ $record,
+ array_merge(
+ $context,
+ [
+ AbstractNormalizer::CALLBACKS => [
+ 'categories' => function (array $newCategories, $record): void {
+ if (!($record instanceof Entity\Podcast)) {
+ return;
+ }
+
+ $categories = $record->getCategories();
+ if ($categories->count() > 0) {
+ foreach ($categories as $existingCategories) {
+ $this->em->remove($existingCategories);
+ }
+ $categories->clear();
+ }
+
+ foreach ($newCategories as $category) {
+ $podcastCategory = new Entity\PodcastCategory($record, $category);
+ $this->em->persist($podcastCategory);
+ $categories->add($podcastCategory);
+ }
+ },
+ ],
+ ]
+ )
+ );
+ }
+
+ protected function processFiles(
+ ServerRequest $request,
+ Entity\Podcast $record
+ ): void {
+ $files = $request->getUploadedFiles();
+
+ $artwork = $files['artwork_file'] ?? null;
+ if ($artwork instanceof UploadedFileInterface && UPLOAD_ERR_OK === $artwork->getError()) {
+ $this->podcastRepository->writePodcastArt(
+ $record,
+ $artwork->getStream()->getContents()
+ );
+
+ $this->em->persist($record);
+ $this->em->flush();
+ }
+ }
+}
diff --git a/src/Controller/Frontend/PublicPages/PodcastEpisodeController.php b/src/Controller/Frontend/PublicPages/PodcastEpisodeController.php
new file mode 100644
index 000000000..896a384c9
--- /dev/null
+++ b/src/Controller/Frontend/PublicPages/PodcastEpisodeController.php
@@ -0,0 +1,78 @@
+getRouter();
+ $station = $request->getStation();
+
+ if (!$station->getEnablePublicPage()) {
+ throw new StationNotFoundException();
+ }
+
+ $podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcast_id);
+
+ if ($podcast === null) {
+ throw new PodcastNotFoundException();
+ }
+
+ $episode = $this->episodeRepository->fetchEpisodeForStation($station, $episode_id);
+
+ $podcastEpisodesLink = (string)$router->named(
+ 'public:podcast:episodes',
+ [
+ 'station_id' => $station->getId(),
+ 'podcast_id' => $podcast_id,
+ ]
+ );
+
+ if (!$episode->isPublished()) {
+ $request->getFlash()->addMessage(__('Episode not found.'), Flash::ERROR);
+ return $response->withRedirect($podcastEpisodesLink);
+ }
+
+ $feedLink = $router->named(
+ 'public:podcast:feed',
+ [
+ 'station_id' => $station->getId(),
+ 'podcast_id' => $podcast->getId(),
+ ]
+ );
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'frontend/public/podcast-episode',
+ [
+ 'episode' => $episode,
+ 'feedLink' => $feedLink,
+ 'podcast' => $podcast,
+ 'podcastEpisodesLink' => $podcastEpisodesLink,
+ 'station' => $station,
+ ]
+ );
+ }
+}
diff --git a/src/Controller/Frontend/PublicPages/PodcastEpisodesController.php b/src/Controller/Frontend/PublicPages/PodcastEpisodesController.php
new file mode 100644
index 000000000..6eb3a0425
--- /dev/null
+++ b/src/Controller/Frontend/PublicPages/PodcastEpisodesController.php
@@ -0,0 +1,87 @@
+getRouter();
+ $station = $request->getStation();
+
+ if (!$station->getEnablePublicPage()) {
+ throw new StationNotFoundException();
+ }
+
+ $podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcast_id);
+
+ if ($podcast === null) {
+ throw new PodcastNotFoundException();
+ }
+
+ $publishedEpisodes = $this->episodeRepository->fetchPublishedEpisodesForPodcast($podcast);
+
+ // Reverse sort order according to the calculated publishing timestamp
+ usort(
+ $publishedEpisodes,
+ static function ($prevEpisode, $nextEpisode) {
+ $prevPublishedAt = $prevEpisode->getPublishedAt ?? $prevEpisode->getCreatedAt();
+ $nextPublishedAt = $nextEpisode->getPublishedAt ?? $nextEpisode->getCreatedAt();
+
+ return ($nextPublishedAt <=> $prevPublishedAt);
+ }
+ );
+
+ $podcastsLink = (string)$router->fromHere(
+ 'public:podcasts',
+ [
+ 'station_id' => $station->getId(),
+ ]
+ );
+
+ if (count($publishedEpisodes) === 0) {
+ $request->getFlash()->addMessage(__('No episodes found.'), Flash::ERROR);
+ return $response->withRedirect($podcastsLink);
+ }
+
+ $feedLink = $router->named(
+ 'public:podcast:feed',
+ [
+ 'station_id' => $station->getId(),
+ 'podcast_id' => $podcast->getId(),
+ ]
+ );
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'frontend/public/podcast-episodes',
+ [
+ 'episodes' => $publishedEpisodes,
+ 'feedLink' => $feedLink,
+ 'podcast' => $podcast,
+ 'podcastsLink' => $podcastsLink,
+ 'station' => $station,
+ ]
+ );
+ }
+}
diff --git a/src/Controller/Frontend/PublicPages/PodcastFeedController.php b/src/Controller/Frontend/PublicPages/PodcastFeedController.php
new file mode 100644
index 000000000..a35a5d0e2
--- /dev/null
+++ b/src/Controller/Frontend/PublicPages/PodcastFeedController.php
@@ -0,0 +1,358 @@
+router = $request->getRouter();
+
+ $station = $request->getStation();
+
+ if (!$station->getEnablePublicPage()) {
+ throw new StationNotFoundException();
+ }
+
+ $podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcast_id);
+
+ if ($podcast === null) {
+ throw new PodcastNotFoundException();
+ }
+
+ if (!$this->checkHasPublishedEpisodes($podcast)) {
+ throw new PodcastNotFoundException();
+ }
+
+ $generatedRss = $this->generateRssFeed($podcast, $station, $request);
+
+ $response->getBody()->write($generatedRss);
+
+ return $response->withHeader('Content-Type', 'application/rss+xml');
+ }
+
+ protected function checkHasPublishedEpisodes(Podcast $podcast): bool
+ {
+ /** @var PodcastEpisode $episode */
+ foreach ($podcast->getEpisodes() as $episode) {
+ if ($episode->isPublished()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function generateRssFeed(
+ Podcast $podcast,
+ Station $station,
+ ServerRequest $serverRequest
+ ): string {
+ $rssWriter = $this->createRssWriter();
+
+ $channel = $this->buildRssChannelForPodcast($podcast, $station, $serverRequest);
+
+ return $rssWriter->writeChannel($channel);
+ }
+
+ protected function createRssWriter(): RssWriter
+ {
+ $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());
+ $rssWriter->registerWriter(new DublinCoreWriter());
+
+ return $rssWriter;
+ }
+
+ protected 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());
+
+ $channelLink = $podcast->getLink();
+ if (empty($channelLink)) {
+ $channelLink = $serverRequest->getRouter()->fromHere(
+ route_name: 'public:podcast:episodes',
+ absolute: true
+ );
+ }
+ $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);
+ $channel->setItems($rssItems);
+
+ $containsExplicitContent = $this->rssItemsContainsExplicitContent($rssItems);
+
+ $itunesChannel = new ItunesChannel();
+ $itunesChannel->setExplicit($containsExplicitContent);
+ $itunesChannel->setImage($rssImage->getUrl());
+ $itunesChannel->setCategories($this->buildItunesCategoriesForPodcast($podcast));
+
+ $channel->addExtension($itunesChannel);
+ $channel->addExtension(new Sy());
+ $channel->addExtension(new Slash());
+ $channel->addExtension(
+ (new AtomLink())
+ ->setRel('self')
+ ->setHref((string)$serverRequest->getUri())
+ ->setType('application/rss+xml')
+ );
+ $channel->addExtension(new DublinCore());
+
+ return $channel;
+ }
+
+ /**
+ * @return RssCategory[]
+ */
+ protected 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[]
+ */
+ protected function buildItunesCategoriesForPodcast(Podcast $podcast): array
+ {
+ return $podcast->getCategories()->map(
+ function (PodcastCategory $podcastCategory) {
+ return (null === $podcastCategory->getSubTitle())
+ ? $podcastCategory->getTitle()
+ : [
+ $podcastCategory->getTitle(),
+ $podcastCategory->getSubTitle(),
+ ];
+ }
+ )->getValues();
+ }
+
+ protected function buildRssImageForPodcast(Podcast $podcast, Station $station): RssImage
+ {
+ $stationFilesystems = new StationFilesystems($station);
+ $podcastsFilesystem = $stationFilesystems->getPodcastsFilesystem();
+
+ $rssImage = new RssImage();
+
+ $podcastArtworkSrc = (string)UriResolver::resolve(
+ $this->router->getBaseUrl(),
+ $this->stationRepository->getDefaultAlbumArtUrl($station)
+ );
+
+ if ($podcastsFilesystem->fileExists(Podcast::getArtPath($podcast->getId()))) {
+ $podcastArtworkSrc = $this->router->fromHere(
+ route_name: 'api:stations:podcast:art',
+ route_params: ['podcast_id' => $podcast->getId() . '|' . $podcast->getArtUpdatedAt()],
+ absolute: true
+ );
+ }
+
+ $rssImage->setUrl($podcastArtworkSrc);
+ $rssImage->setLink($podcast->getLink());
+ $rssImage->setTitle($podcast->getTitle());
+
+ return $rssImage;
+ }
+
+ /**
+ * @return RssItem[]
+ */
+ protected 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->getId());
+
+ $rssItem->setGuid($rssGuid);
+ $rssItem->setTitle($episode->getTitle());
+ $rssItem->setDescription($episode->getDescription());
+
+ $episodeLink = $episode->getLink();
+ if (empty($episodeLink)) {
+ $episodeLink = $this->router->fromHere(
+ route_name: 'public:podcast:episode',
+ route_params: ['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;
+ }
+
+ protected function buildRssEnclosureForPodcastMedia(
+ PodcastEpisode $episode,
+ Station $station
+ ): RssEnclosure {
+ $rssEnclosure = new RssEnclosure();
+
+ $podcastMediaPlayUrl = $this->router->fromHere(
+ route_name: 'api:stations:podcast:episode:download',
+ route_params: ['episode_id' => $episode->getId()],
+ absolute: true
+ );
+
+ $rssEnclosure->setUrl($podcastMediaPlayUrl);
+
+ $podcastMedia = $episode->getMedia();
+ $rssEnclosure->setType($podcastMedia->getMimeType());
+ $rssEnclosure->setLength($podcastMedia->getLength());
+
+ return $rssEnclosure;
+ }
+
+ protected function buildItunesImageForEpisode(PodcastEpisode $episode, Station $station): string
+ {
+ $stationFilesystems = new StationFilesystems($station);
+ $podcastsFilesystem = $stationFilesystems->getPodcastsFilesystem();
+
+ $episodeArtworkSrc = (string)UriResolver::resolve(
+ $this->router->getBaseUrl(),
+ $this->stationRepository->getDefaultAlbumArtUrl($station)
+ );
+
+ if ($podcastsFilesystem->fileExists(PodcastEpisode::getArtPath($episode->getId()))) {
+ $episodeArtworkSrc = $this->router->fromHere(
+ route_name: 'api:stations:podcast:episode:art',
+ route_params: ['episode_id' => $episode->getId() . '|' . $episode->getArtUpdatedAt()],
+ absolute: true
+ );
+ }
+
+ return $episodeArtworkSrc;
+ }
+
+ /**
+ * @param RssItem[] $rssItems
+ */
+ protected function rssItemsContainsExplicitContent(array $rssItems): bool
+ {
+ foreach ($rssItems as $rssItem) {
+ foreach ($rssItem->getExtensions() as $extension) {
+ if (($extension instanceof ItunesItem) === false) {
+ continue;
+ }
+
+ /** @var ItunesItem $extension */
+ if ($extension->getExplicit()) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Controller/Frontend/PublicPages/PodcastsController.php b/src/Controller/Frontend/PublicPages/PodcastsController.php
new file mode 100644
index 000000000..51fa04a6f
--- /dev/null
+++ b/src/Controller/Frontend/PublicPages/PodcastsController.php
@@ -0,0 +1,35 @@
+getStation();
+
+ if (!$station->getEnablePublicPage()) {
+ throw new StationNotFoundException();
+ }
+
+ $publishedPodcasts = $this->podcastRepository->fetchPublishedPodcastsForStation($station);
+
+ return $request->getView()->renderToResponse($response, 'frontend/public/podcasts', [
+ 'podcasts' => $publishedPodcasts,
+ 'station' => $station,
+ ]);
+ }
+}
diff --git a/src/Controller/Stations/PodcastsAction.php b/src/Controller/Stations/PodcastsAction.php
new file mode 100644
index 000000000..1fa3182b6
--- /dev/null
+++ b/src/Controller/Stations/PodcastsAction.php
@@ -0,0 +1,35 @@
+getStation();
+
+ $userLocale = (string)$request->getCustomization()->getLocale();
+
+ $languageOptions = Languages::getNames($userLocale);
+ $categoriesOptions = PodcastCategory::getAvailableCategories();
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'stations/podcasts/index',
+ [
+ 'stationId' => $station->getId(),
+ 'stationTz' => $station->getTimezone(),
+ 'languageOptions' => $languageOptions,
+ 'categoriesOptions' => $categoriesOptions,
+ ]
+ );
+ }
+}
diff --git a/src/Entity/Api/Podcast.php b/src/Entity/Api/Podcast.php
new file mode 100644
index 000000000..38b3fdb05
--- /dev/null
+++ b/src/Entity/Api/Podcast.php
@@ -0,0 +1,69 @@
+getReference('station');
+
+ $podcastStorage = $station->getPodcastsStorageLocation();
+
+ $podcast = new Entity\Podcast($podcastStorage);
+
+ $podcast->setTitle('The AzuraTest Podcast');
+ $podcast->setLink('https://demo.azuracast.com');
+ $podcast->setLanguage('en');
+ $podcast->setDescription('The unofficial testing podcast for the AzuraCast development team.');
+ $em->persist($podcast);
+
+ $category = new Entity\PodcastCategory($podcast, 'Technology');
+ $em->persist($category);
+
+ $em->flush();
+
+ $this->setReference('podcast', $podcast);
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getDependencies(): array
+ {
+ return [
+ Station::class,
+ ];
+ }
+}
diff --git a/src/Entity/Fixture/PodcastEpisode.php b/src/Entity/Fixture/PodcastEpisode.php
new file mode 100644
index 000000000..26af24b18
--- /dev/null
+++ b/src/Entity/Fixture/PodcastEpisode.php
@@ -0,0 +1,92 @@
+mediaRepo = $mediaRepo;
+ }
+
+ public function load(ObjectManager $em): void
+ {
+ $podcastsSkeletonDir = getenv('INIT_PODCASTS_PATH');
+
+ if (empty($podcastsSkeletonDir) || !is_dir($podcastsSkeletonDir)) {
+ return;
+ }
+
+ /** @var Entity\Podcast $podcast */
+ $podcast = $this->getReference('podcast');
+
+ $storageLocation = $podcast->getStorageLocation();
+ $fs = $storageLocation->getFilesystem();
+
+ $finder = (new Finder())
+ ->files()
+ ->in($podcastsSkeletonDir)
+ ->name('/^.+\.(mp3|aac|ogg|flac)$/i');
+
+ $i = 1;
+
+ $podcastNames = [
+ 'Attack of the %s',
+ 'Introducing: %s!',
+ 'Rants About %s',
+ 'The %s Where Everyone Yells',
+ '%s? It\'s AzuraCastastic!',
+ ];
+
+ $podcastFillers = [
+ 'Content',
+ 'Unicorn Login Screen',
+ 'Default Error Message',
+ ];
+
+ foreach ($finder as $file) {
+ $filePath = $file->getPathname();
+ $fileBaseName = basename($filePath);
+
+ // Create an episode and associate it with the podcast/media.
+ $episode = new Entity\PodcastEpisode($podcast);
+
+ $podcastName = $podcastNames[array_rand($podcastNames)];
+ $podcastFiller = $podcastFillers[array_rand($podcastFillers)];
+
+ $episode->setTitle('Episode ' . $i . ': ' . sprintf($podcastName, $podcastFiller));
+ $episode->setDescription('Another great episode!');
+ $episode->setExplicit(false);
+
+ $em->persist($episode);
+ $em->flush();
+
+ $this->mediaRepo->upload(
+ $episode,
+ $fileBaseName,
+ $filePath,
+ $fs
+ );
+
+ $i++;
+ }
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getDependencies(): array
+ {
+ return [
+ Podcast::class,
+ ];
+ }
+}
diff --git a/src/Entity/Fixture/Station.php b/src/Entity/Fixture/Station.php
index 2d87a100e..a96be7363 100644
--- a/src/Entity/Fixture/Station.php
+++ b/src/Entity/Fixture/Station.php
@@ -24,16 +24,19 @@ class Station extends AbstractFixture
$mediaStorage = $station->getMediaStorageLocation();
$recordingsStorage = $station->getRecordingsStorageLocation();
+ $podcastsStorage = $station->getPodcastsStorageLocation();
$stationQuota = getenv('INIT_STATION_QUOTA');
if (!empty($stationQuota)) {
$mediaStorage->setStorageQuota($stationQuota);
$recordingsStorage->setStorageQuota($stationQuota);
+ $podcastsStorage->setStorageQuota($stationQuota);
}
$em->persist($station);
$em->persist($mediaStorage);
$em->persist($recordingsStorage);
+ $em->persist($podcastsStorage);
$em->flush();
diff --git a/src/Entity/Metadata.php b/src/Entity/Metadata.php
index 0c835a0c1..8461f25ea 100644
--- a/src/Entity/Metadata.php
+++ b/src/Entity/Metadata.php
@@ -12,6 +12,8 @@ class Metadata
protected ?string $artwork = null;
+ protected string $mimeType = '';
+
public function __construct()
{
$this->tags = new ArrayCollection();
@@ -41,4 +43,14 @@ class Metadata
{
$this->artwork = $artwork;
}
+
+ public function getMimeType(): string
+ {
+ return $this->mimeType;
+ }
+
+ public function setMimeType(string $mimeType): void
+ {
+ $this->mimeType = $mimeType;
+ }
}
diff --git a/src/Entity/Migration/Version20210512225946.php b/src/Entity/Migration/Version20210512225946.php
new file mode 100644
index 000000000..f46c23ee4
--- /dev/null
+++ b/src/Entity/Migration/Version20210512225946.php
@@ -0,0 +1,104 @@
+addSql(
+ 'CREATE TABLE podcast (id CHAR(36) NOT NULL COMMENT \'(DC2Type:guid)\', storage_location_id INT DEFAULT NULL, title VARCHAR(255) NOT NULL, link VARCHAR(255) DEFAULT NULL, description LONGTEXT NOT NULL, language VARCHAR(2) NOT NULL, art_updated_at INT NOT NULL, INDEX IDX_D7E805BDCDDD8AF (storage_location_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB'
+ );
+ $this->addSql(
+ 'CREATE TABLE podcast_category (id INT AUTO_INCREMENT NOT NULL, podcast_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:guid)\', category VARCHAR(255) NOT NULL, INDEX IDX_E633B1E8786136AB (podcast_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB'
+ );
+ $this->addSql(
+ 'CREATE TABLE podcast_episode (id CHAR(36) NOT NULL COMMENT \'(DC2Type:guid)\', podcast_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:guid)\', title VARCHAR(255) NOT NULL, link VARCHAR(255) DEFAULT NULL, description LONGTEXT NOT NULL, publish_at INT DEFAULT NULL, explicit TINYINT(1) NOT NULL, created_at INT NOT NULL, art_updated_at INT NOT NULL, INDEX IDX_77EB2BD0786136AB (podcast_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB'
+ );
+ $this->addSql(
+ 'CREATE TABLE podcast_media (id CHAR(36) NOT NULL COMMENT \'(DC2Type:guid)\', storage_location_id INT DEFAULT NULL, episode_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:guid)\', original_name VARCHAR(200) NOT NULL, length NUMERIC(7, 2) NOT NULL, length_text VARCHAR(10) NOT NULL, path VARCHAR(500) NOT NULL, mime_type VARCHAR(255) NOT NULL, modified_time INT NOT NULL, art_updated_at INT NOT NULL, INDEX IDX_15AD8829CDDD8AF (storage_location_id), UNIQUE INDEX UNIQ_15AD8829362B62A0 (episode_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB'
+ );
+ $this->addSql(
+ 'ALTER TABLE podcast ADD CONSTRAINT FK_D7E805BDCDDD8AF FOREIGN KEY (storage_location_id) REFERENCES storage_location (id) ON DELETE CASCADE'
+ );
+ $this->addSql(
+ 'ALTER TABLE podcast_category ADD CONSTRAINT FK_E633B1E8786136AB FOREIGN KEY (podcast_id) REFERENCES podcast (id) ON DELETE CASCADE'
+ );
+ $this->addSql(
+ 'ALTER TABLE podcast_episode ADD CONSTRAINT FK_77EB2BD0786136AB FOREIGN KEY (podcast_id) REFERENCES podcast (id) ON DELETE CASCADE'
+ );
+ $this->addSql(
+ 'ALTER TABLE podcast_media ADD CONSTRAINT FK_15AD8829CDDD8AF FOREIGN KEY (storage_location_id) REFERENCES storage_location (id) ON DELETE CASCADE'
+ );
+ $this->addSql(
+ 'ALTER TABLE podcast_media ADD CONSTRAINT FK_15AD8829362B62A0 FOREIGN KEY (episode_id) REFERENCES podcast_episode (id) ON DELETE SET NULL'
+ );
+ $this->addSql('ALTER TABLE station ADD podcasts_storage_location_id INT DEFAULT NULL');
+ $this->addSql(
+ 'ALTER TABLE station ADD CONSTRAINT FK_9F39F8B123303CD0 FOREIGN KEY (podcasts_storage_location_id) REFERENCES storage_location (id) ON DELETE SET NULL'
+ );
+ $this->addSql('CREATE INDEX IDX_9F39F8B123303CD0 ON station (podcasts_storage_location_id)');
+ }
+
+ public function postUp(Schema $schema): void
+ {
+ $stations = $this->connection->fetchAllAssociative(
+ 'SELECT id, radio_base_dir FROM station WHERE podcasts_storage_location_id IS NULL ORDER BY id ASC'
+ );
+
+ foreach ($stations as $row) {
+ $stationId = $row['id'];
+
+ $baseDir = $row['radio_base_dir'];
+
+ $this->connection->insert(
+ 'storage_location',
+ [
+ 'type' => 'station_podcasts',
+ 'adapter' => 'local',
+ 'path' => $baseDir . '/podcasts',
+ 'storage_quota' => null,
+ ]
+ );
+
+ $podcastsStorageLocationId = $this->connection->lastInsertId('storage_location');
+
+ $this->connection->update(
+ 'station',
+ [
+ 'podcasts_storage_location_id' => $podcastsStorageLocationId,
+ ],
+ [
+ 'id' => $stationId,
+ ]
+ );
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE podcast_category DROP FOREIGN KEY FK_E633B1E8786136AB');
+ $this->addSql('ALTER TABLE podcast_episode DROP FOREIGN KEY FK_77EB2BD0786136AB');
+ $this->addSql('ALTER TABLE podcast_media DROP FOREIGN KEY FK_15AD8829362B62A0');
+ $this->addSql('DROP TABLE podcast');
+ $this->addSql('DROP TABLE podcast_category');
+ $this->addSql('DROP TABLE podcast_episode');
+ $this->addSql('DROP TABLE podcast_media');
+ $this->addSql('ALTER TABLE station DROP FOREIGN KEY FK_9F39F8B123303CD0');
+ $this->addSql('DROP INDEX IDX_9F39F8B123303CD0 ON station');
+ $this->addSql('ALTER TABLE station DROP podcasts_storage_location_id');
+ }
+}
diff --git a/src/Entity/Podcast.php b/src/Entity/Podcast.php
new file mode 100644
index 000000000..507ae589a
--- /dev/null
+++ b/src/Entity/Podcast.php
@@ -0,0 +1,198 @@
+storage_location = $storageLocation;
+
+ $this->categories = new ArrayCollection();
+ $this->episodes = new ArrayCollection();
+ }
+
+ public function getId(): ?string
+ {
+ return $this->id;
+ }
+
+ public function getStorageLocation(): StorageLocation
+ {
+ return $this->storage_location;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): self
+ {
+ $this->title = $this->truncateString($title);
+
+ return $this;
+ }
+
+ public function getLink(): ?string
+ {
+ return $this->link;
+ }
+
+ public function setLink(?string $link): self
+ {
+ $this->link = $this->truncateString($link);
+
+ return $this;
+ }
+
+ public function getDescription(): string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(string $description): self
+ {
+ $this->description = $this->truncateString($description);
+
+ return $this;
+ }
+
+ public function getLanguage(): string
+ {
+ return $this->language;
+ }
+
+ public function setLanguage(string $language): self
+ {
+ $this->language = $this->truncateString($language);
+
+ return $this;
+ }
+
+ public function getArtUpdatedAt(): int
+ {
+ return $this->art_updated_at;
+ }
+
+ public function setArtUpdatedAt(int $art_updated_at): self
+ {
+ $this->art_updated_at = $art_updated_at;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection|PodcastCategory[]
+ */
+ public function getCategories(): Collection
+ {
+ return $this->categories;
+ }
+
+ /**
+ * @return Collection|PodcastEpisode[]
+ */
+ public function getEpisodes(): Collection
+ {
+ return $this->episodes;
+ }
+
+ public static function getArtPath(string $uniqueId): string
+ {
+ return self::DIR_PODCAST_ARTWORK . '/' . $uniqueId . '.jpg';
+ }
+}
diff --git a/src/Entity/PodcastCategory.php b/src/Entity/PodcastCategory.php
new file mode 100644
index 000000000..f79d5f459
--- /dev/null
+++ b/src/Entity/PodcastCategory.php
@@ -0,0 +1,233 @@
+podcast = $podcast;
+ $this->category = $this->truncateString($category);
+ }
+
+ public function getPodcast(): Podcast
+ {
+ return $this->podcast;
+ }
+
+ public function getCategory(): string
+ {
+ return $this->category;
+ }
+
+ public function getTitle(): string
+ {
+ return (explode(self::CATEGORY_SEPARATOR, $this->category))[0];
+ }
+
+ public function getSubTitle(): ?string
+ {
+ return (str_contains($this->category, self::CATEGORY_SEPARATOR))
+ ? (explode(self::CATEGORY_SEPARATOR, $this->category))[1]
+ : null;
+ }
+
+ /**
+ * @return mixed[]
+ */
+ public static function getAvailableCategories(): array
+ {
+ $categories = [
+ 'Arts' => [
+ 'Books',
+ 'Design',
+ 'Fashion & Beauty',
+ 'Food',
+ 'Performing Arts',
+ 'Visual Arts',
+ ],
+ 'Business' => [
+ 'Careers',
+ 'Entrepreneurship',
+ 'Investing',
+ 'Management',
+ 'Marketing',
+ 'Non-Profit',
+ ],
+ 'Comedy' => [
+ 'Comedy Interviews',
+ 'Improv',
+ 'Stand-Up',
+ ],
+ 'Education' => [
+ 'Courses',
+ 'How To',
+ 'Language Learning',
+ 'Self-Improvement',
+ ],
+ 'Fiction' => [
+ 'Comedy Fiction',
+ 'Drama',
+ 'Science Fiction',
+ ],
+ 'Government' => [
+ '',
+ ],
+ 'History' => [
+ '',
+ ],
+ 'Health & Fitness' => [
+ 'Alternative Health',
+ 'Fitness',
+ 'Medicine',
+ 'Mental Health',
+ 'Nutrition',
+ 'Sexuality',
+ ],
+ 'Kids & Family' => [
+ 'Parenting',
+ 'Pets & Animals',
+ 'Stories for Kids',
+ ],
+ 'Leisure' => [
+ 'Animation & Manga',
+ 'Automotive',
+ 'Aviation',
+ 'Crafts',
+ 'Games',
+ 'Hobbies',
+ 'Home & Garden',
+ 'Video Games',
+ ],
+ 'Music' => [
+ 'Music Commentary',
+ 'Music History',
+ 'Music Interviews',
+ ],
+ 'News' => [
+ 'Business News',
+ 'Daily News',
+ 'Entertainment News',
+ 'News Commentary',
+ 'Politics',
+ 'Sports News',
+ 'Tech News',
+ ],
+ 'Religion & Spirituality' => [
+ 'Buddhism',
+ 'Christianity',
+ 'Hinduism',
+ 'Islam',
+ 'Judaism',
+ 'Religion',
+ 'Spirituality',
+ ],
+ 'Science' => [
+ 'Astronomy',
+ 'Chemistry',
+ 'Earth Sciences',
+ 'Life Sciences',
+ 'Mathematics',
+ 'Natural Sciences',
+ 'Nature',
+ 'Physics',
+ 'Social Sciences',
+ ],
+ 'Society & Culture' => [
+ 'Documentary',
+ 'Personal Journals',
+ 'Philosophy',
+ 'Places & Travel',
+ 'Relationships',
+ ],
+ 'Sports' => [
+ 'Baseball',
+ 'Basketball',
+ 'Cricket',
+ 'Fantasy Sports',
+ 'Football',
+ 'Golf',
+ 'Hockey',
+ 'Rugby',
+ 'Running',
+ 'Soccer',
+ 'Swimming',
+ 'Tennis',
+ 'Volleyball',
+ 'Wilderness',
+ 'Wrestling',
+ ],
+ 'Technology' => [
+ '',
+ ],
+ 'True Crime' => [
+ '',
+ ],
+ 'TV & Film' => [
+ 'After Shows',
+ 'Film History',
+ 'Film Interviews',
+ 'Film Reviews',
+ 'TV Reviews',
+ ],
+ ];
+
+ $categorySelect = [];
+ foreach ($categories as $categoryName => $subTitles) {
+ foreach ($subTitles as $subTitle) {
+ if ('' === $subTitle) {
+ $categorySelect[$categoryName] = $categoryName;
+ } else {
+ $selectKey = $categoryName . self::CATEGORY_SEPARATOR . $subTitle;
+ $categorySelect[$selectKey] = $categoryName . ' > ' . $subTitle;
+ }
+ }
+ }
+
+ return $categorySelect;
+ }
+}
diff --git a/src/Entity/PodcastEpisode.php b/src/Entity/PodcastEpisode.php
new file mode 100644
index 000000000..005772a9a
--- /dev/null
+++ b/src/Entity/PodcastEpisode.php
@@ -0,0 +1,231 @@
+podcast = $podcast;
+ $this->created_at = time();
+ }
+
+ public function getId(): ?string
+ {
+ return $this->id;
+ }
+
+ public function getPodcast(): Podcast
+ {
+ return $this->podcast;
+ }
+
+ public function setMedia(?PodcastMedia $media): void
+ {
+ $this->media = $media;
+ }
+
+ public function getMedia(): ?PodcastMedia
+ {
+ return $this->media;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): self
+ {
+ $this->title = $this->truncateString($title);
+
+ return $this;
+ }
+
+ public function getLink(): ?string
+ {
+ return $this->link;
+ }
+
+ public function setLink(?string $link): self
+ {
+ $this->link = $this->truncateString($link);
+
+ return $this;
+ }
+
+ public function getDescription(): string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(string $description): self
+ {
+ $this->description = $this->truncateString($description);
+
+ return $this;
+ }
+
+ public function getPublishAt(): ?int
+ {
+ return $this->publish_at;
+ }
+
+ public function setPublishAt(?int $publishAt): self
+ {
+ $this->publish_at = $publishAt;
+
+ return $this;
+ }
+
+ public function getExplicit(): bool
+ {
+ return $this->explicit;
+ }
+
+ public function setExplicit(bool $explicit): self
+ {
+ $this->explicit = $explicit;
+
+ return $this;
+ }
+
+ public function getCreatedAt(): int
+ {
+ return $this->created_at;
+ }
+
+ public function setCreatedAt(int $createdAt): self
+ {
+ $this->created_at = $createdAt;
+
+ return $this;
+ }
+
+ public function getArtUpdatedAt(): int
+ {
+ return $this->art_updated_at;
+ }
+
+ public function setArtUpdatedAt(int $art_updated_at): self
+ {
+ $this->art_updated_at = $art_updated_at;
+
+ return $this;
+ }
+
+ public static function getArtPath(string $uniqueId): string
+ {
+ return self::DIR_PODCAST_EPISODE_ARTWORK . '/' . $uniqueId . '.jpg';
+ }
+
+ public function isPublished(): bool
+ {
+ if ($this->getPublishAt() !== null && $this->getPublishAt() > time()) {
+ return false;
+ }
+
+ if ($this->getMedia() === null) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/Entity/PodcastMedia.php b/src/Entity/PodcastMedia.php
new file mode 100644
index 000000000..34ee84057
--- /dev/null
+++ b/src/Entity/PodcastMedia.php
@@ -0,0 +1,242 @@
+storage_location = $storageLocation;
+ }
+
+ public function getId(): ?string
+ {
+ return $this->id;
+ }
+
+ public function getStorageLocation(): StorageLocation
+ {
+ return $this->storage_location;
+ }
+
+ public function getEpisode(): ?PodcastEpisode
+ {
+ return $this->episode;
+ }
+
+ public function setEpisode(?PodcastEpisode $episode): self
+ {
+ $this->episode = $episode;
+
+ return $this;
+ }
+
+ public function getOriginalName(): string
+ {
+ return $this->original_name;
+ }
+
+ public function setOriginalName(string $originalName): self
+ {
+ $this->original_name = $this->truncateString($originalName);
+
+ return $this;
+ }
+
+ public function getLength(): float
+ {
+ return (float)$this->length;
+ }
+
+ public function setLength(float $length): self
+ {
+ $lengthMin = floor($length / 60);
+ $lengthSec = $length % 60;
+
+ $this->length = (float)$length;
+ $this->length_text = $lengthMin . ':' . str_pad((string)$lengthSec, 2, '0', STR_PAD_LEFT);
+
+ return $this;
+ }
+
+ public function getLengthText(): string
+ {
+ return $this->length_text;
+ }
+
+ public function setLengthText(string $lengthText): self
+ {
+ $this->length_text = $lengthText;
+
+ return $this;
+ }
+
+ public function getPath(): string
+ {
+ return $this->path;
+ }
+
+ public function setPath(string $path): self
+ {
+ $this->path = $path;
+
+ return $this;
+ }
+
+ public function getMimeType(): string
+ {
+ return $this->mime_type;
+ }
+
+ public function setMimeType(string $mimeType): self
+ {
+ $this->mime_type = $mimeType;
+
+ return $this;
+ }
+
+ public function getModifiedTime(): int
+ {
+ return $this->modified_time;
+ }
+
+ public function setModifiedTime(int $modifiedTime): self
+ {
+ $this->modified_time = $modifiedTime;
+
+ return $this;
+ }
+
+ public function getArtUpdatedAt(): int
+ {
+ return $this->art_updated_at;
+ }
+
+ public function setArtUpdatedAt(int $art_updated_at): self
+ {
+ $this->art_updated_at = $art_updated_at;
+
+ return $this;
+ }
+
+ /**
+ * @param string|float|null $seconds
+ */
+ protected function parseSeconds($seconds = null): ?float
+ {
+ if ($seconds === '') {
+ return null;
+ }
+
+ if (false !== strpos($seconds, ':')) {
+ $sec = 0;
+ foreach (array_reverse(explode(':', $seconds)) as $k => $v) {
+ $sec += (60 ** (int)$k) * (int)$v;
+ }
+
+ return $sec;
+ }
+
+ return $seconds;
+ }
+}
diff --git a/src/Entity/Repository/PodcastEpisodeRepository.php b/src/Entity/Repository/PodcastEpisodeRepository.php
new file mode 100644
index 000000000..6aad40db1
--- /dev/null
+++ b/src/Entity/Repository/PodcastEpisodeRepository.php
@@ -0,0 +1,133 @@
+fetchEpisodeForStorageLocation(
+ $station->getPodcastsStorageLocation(),
+ $episodeId
+ );
+ }
+
+ public function fetchEpisodeForStorageLocation(
+ StorageLocation $storageLocation,
+ string $episodeId
+ ): ?PodcastEpisode {
+ return $this->em->createQuery(
+ <<<'DQL'
+ SELECT pe
+ FROM App\Entity\PodcastEpisode pe
+ JOIN pe.podcast p
+ WHERE pe.id = :id
+ AND p.storage_location = :storageLocation
+ DQL
+ )->setParameter('id', $episodeId)
+ ->setParameter('storageLocation', $storageLocation)
+ ->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)
+ ->getQuery()
+ ->getResult();
+
+ return array_filter(
+ $episodes,
+ static function (PodcastEpisode $episode) {
+ return $episode->isPublished();
+ }
+ );
+ }
+
+ public function writeEpisodeArt(
+ PodcastEpisode $episode,
+ string $rawArtworkString
+ ): void {
+ $episodeArtwork = $this->imageManager->make($rawArtworkString);
+ $episodeArtwork->fit(
+ 3000,
+ 3000,
+ function (Constraint $constraint): void {
+ $constraint->upsize();
+ }
+ );
+
+ $episodeArtworkPath = PodcastEpisode::getArtPath($episode->getId());
+ $episodeArtworkStream = $episodeArtwork->stream('jpg');
+
+ $fsPodcasts = $episode->getPodcast()->getStorageLocation()->getFilesystem();
+ $fsPodcasts->writeStream($episodeArtworkPath, $episodeArtworkStream->detach());
+
+ $episode->setArtUpdatedAt(time());
+ }
+
+ public function removeEpisodeArt(
+ PodcastEpisode $episode,
+ ?ExtendedFilesystemInterface $fs = null
+ ): void {
+ $artworkPath = PodcastEpisode::getArtPath($episode->getId());
+
+ $fs ??= $episode->getPodcast()->getStorageLocation()->getFilesystem();
+
+ try {
+ $fs->delete($artworkPath);
+ } catch (UnableToDeleteFile) {
+ }
+
+ $episode->setArtUpdatedAt(0);
+ }
+
+ public function delete(
+ PodcastEpisode $episode,
+ ?ExtendedFilesystemInterface $fs = null
+ ): void {
+ $fs ??= $episode->getPodcast()->getStorageLocation()->getFilesystem();
+
+ if (null !== $episode->getMedia()) {
+ $this->podcastMediaRepo->delete($episode->getMedia(), $fs);
+ }
+
+ $this->removeEpisodeArt($episode, $fs);
+
+ $this->em->remove($episode);
+ $this->em->flush();
+ }
+}
diff --git a/src/Entity/Repository/PodcastMediaRepository.php b/src/Entity/Repository/PodcastMediaRepository.php
new file mode 100644
index 000000000..8b3d6108f
--- /dev/null
+++ b/src/Entity/Repository/PodcastMediaRepository.php
@@ -0,0 +1,96 @@
+getPodcast();
+ $storageLocation = $podcast->getStorageLocation();
+
+ $fs ??= $storageLocation->getFilesystem();
+
+ // Do an early metadata check of the new media to avoid replacing a valid file with an invalid one.
+ $metadata = $this->metadataService->readMetadata($uploadPath);
+
+ if (!in_array($metadata->getMimeType(), ['audio/x-m4a', 'audio/mpeg'])) {
+ throw new InvalidPodcastMediaFileException(
+ 'Invalid Podcast Media mime type: ' . $metadata->getMimeType()
+ );
+ }
+
+ if ($episode->getMedia() instanceof PodcastMedia) {
+ $this->delete($episode->getMedia(), $fs);
+ $episode->setMedia(null);
+ }
+
+ $ext = pathinfo($originalPath, PATHINFO_EXTENSION);
+ $path = $podcast->getId() . '/' . $episode->getId() . '.' . $ext;
+
+ $podcastMedia = new PodcastMedia($storageLocation);
+ $podcastMedia->setPath($path);
+ $podcastMedia->setOriginalName(basename($originalPath));
+
+ // Load metadata from local file while it's available.
+ $podcastMedia->setLength($metadata->getDuration());
+ $podcastMedia->setMimeType($metadata->getMimeType());
+
+ // Upload local file remotely.
+ $fs->uploadAndDeleteOriginal($uploadPath, $path);
+
+ $podcastMedia->setEpisode($episode);
+ $this->em->persist($podcastMedia);
+
+ $episode->setMedia($podcastMedia);
+ $this->em->persist($episode);
+ $this->em->flush();
+
+ return $metadata->getArtwork();
+ }
+
+ public function delete(
+ PodcastMedia $media,
+ ?ExtendedFilesystemInterface $fs = null
+ ): void {
+ $fs ??= $media->getStorageLocation()->getFilesystem();
+
+ try {
+ $fs->delete($media->getPath());
+ } catch (UnableToDeleteFile) {
+ }
+
+ $this->em->remove($media);
+ $this->em->flush();
+ }
+}
diff --git a/src/Entity/Repository/PodcastRepository.php b/src/Entity/Repository/PodcastRepository.php
new file mode 100644
index 000000000..de2774aa5
--- /dev/null
+++ b/src/Entity/Repository/PodcastRepository.php
@@ -0,0 +1,134 @@
+fetchPodcastForStorageLocation($station->getPodcastsStorageLocation(), $podcastId);
+ }
+
+ public function fetchPodcastForStorageLocation(
+ StorageLocation $storageLocation,
+ string $podcastId
+ ): ?Podcast {
+ return $this->repository->findOneBy(
+ [
+ 'id' => $podcastId,
+ 'storage_location' => $storageLocation,
+ ]
+ );
+ }
+
+ /**
+ * @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,
+ ?ExtendedFilesystemInterface $fs = null
+ ): void {
+ $fs ??= $podcast->getStorageLocation()->getFilesystem();
+
+ $podcastArtwork = $this->imageManager->make($rawArtworkString);
+ $podcastArtwork->fit(
+ 3000,
+ 3000,
+ function (Constraint $constraint): void {
+ $constraint->upsize();
+ }
+ );
+
+ $podcastArtworkPath = Podcast::getArtPath($podcast->getId());
+ $podcastArtworkStream = $podcastArtwork->stream('jpg');
+
+ $fs->writeStream($podcastArtworkPath, $podcastArtworkStream->detach());
+
+ $podcast->setArtUpdatedAt(time());
+ }
+
+ public function removePodcastArt(
+ Podcast $podcast,
+ ?ExtendedFilesystemInterface $fs = null
+ ): void {
+ $fs ??= $podcast->getStorageLocation()->getFilesystem();
+
+ $artworkPath = Podcast::getArtPath($podcast->getId());
+
+ try {
+ $fs->delete($artworkPath);
+ } catch (UnableToDeleteFile) {
+ }
+
+ $podcast->setArtUpdatedAt(0);
+ }
+
+ public function delete(
+ Podcast $podcast,
+ ?ExtendedFilesystemInterface $fs = null
+ ): void {
+ $fs ??= $podcast->getStorageLocation()->getFilesystem();
+
+ foreach ($podcast->getEpisodes() as $episode) {
+ $this->podcastEpisodeRepo->delete($episode, $fs);
+ }
+
+ $this->removePodcastArt($podcast, $fs);
+
+ $this->em->remove($podcast);
+ $this->em->flush();
+ }
+}
diff --git a/src/Entity/Repository/StorageLocationRepository.php b/src/Entity/Repository/StorageLocationRepository.php
index 0eaa31a37..2cf57b31d 100644
--- a/src/Entity/Repository/StorageLocationRepository.php
+++ b/src/Entity/Repository/StorageLocationRepository.php
@@ -95,6 +95,11 @@ class StorageLocationRepository extends Repository
->setParameter('storageLocation', $storageLocation);
break;
+ case Entity\StorageLocation::TYPE_STATION_PODCASTS:
+ $qb->where('s.podcasts_storage_location = :storageLocation')
+ ->setParameter('storageLocation', $storageLocation);
+ break;
+
case Entity\StorageLocation::TYPE_BACKUP:
default:
return [];
diff --git a/src/Entity/Station.php b/src/Entity/Station.php
index 4b81cc4a5..8672204a1 100644
--- a/src/Entity/Station.php
+++ b/src/Entity/Station.php
@@ -341,6 +341,19 @@ class Station
*/
protected $recordings_storage_location;
+ /**
+ * @ORM\ManyToOne(targetEntity="StorageLocation")
+ * @ORM\JoinColumns({
+ * @ORM\JoinColumn(name="podcasts_storage_location_id", referencedColumnName="id", onDelete="SET NULL")
+ * })
+ *
+ * @DeepNormalize(true)
+ * @Serializer\MaxDepth(1)
+ *
+ * @var StorageLocation
+ */
+ protected $podcasts_storage_location;
+
/**
* @ORM\OneToMany(targetEntity="StationStreamer", mappedBy="station")
* @var Collection
@@ -676,6 +689,19 @@ class Station
$this->recordings_storage_location = $storageLocation;
}
+
+ if (null === $this->podcasts_storage_location) {
+ $storageLocation = new StorageLocation(
+ StorageLocation::TYPE_STATION_PODCASTS,
+ StorageLocation::ADAPTER_LOCAL
+ );
+
+ $podcastsPath = $this->getRadioBaseDir() . '/podcasts';
+ $this->ensureDirectoryExists($podcastsPath);
+ $storageLocation->setPath($podcastsPath);
+
+ $this->podcasts_storage_location = $storageLocation;
+ }
}
protected function ensureDirectoryExists(string $dirname): void
@@ -969,6 +995,20 @@ class Station
$this->recordings_storage_location = $storageLocation;
}
+ public function getPodcastsStorageLocation(): StorageLocation
+ {
+ return $this->podcasts_storage_location;
+ }
+
+ public function setPodcastsStorageLocation(StorageLocation $storageLocation): void
+ {
+ if (StorageLocation::TYPE_STATION_PODCASTS !== $storageLocation->getType()) {
+ throw new InvalidArgumentException('Storage location must be for station podcasts.');
+ }
+
+ $this->podcasts_storage_location = $storageLocation;
+ }
+
public function getPermissions(): Collection
{
return $this->permissions;
diff --git a/src/Entity/StationMedia.php b/src/Entity/StationMedia.php
index ee9450193..cf1005399 100644
--- a/src/Entity/StationMedia.php
+++ b/src/Entity/StationMedia.php
@@ -5,7 +5,6 @@
namespace App\Entity;
use App\Annotations\AuditLog;
-use App\Flysystem\FilesystemManager;
use App\Normalizer\Annotation\DeepNormalize;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
diff --git a/src/Entity/StorageLocation.php b/src/Entity/StorageLocation.php
index 97929329b..867333d37 100644
--- a/src/Entity/StorageLocation.php
+++ b/src/Entity/StorageLocation.php
@@ -38,6 +38,7 @@ class StorageLocation implements \Stringable
public const TYPE_BACKUP = 'backup';
public const TYPE_STATION_MEDIA = 'station_media';
public const TYPE_STATION_RECORDINGS = 'station_recordings';
+ public const TYPE_STATION_PODCASTS = 'station_podcasts';
public const ADAPTER_LOCAL = 'local';
public const ADAPTER_S3 = 's3';
diff --git a/src/Exception/InvalidPodcastMediaFileException.php b/src/Exception/InvalidPodcastMediaFileException.php
new file mode 100644
index 000000000..e3da2787a
--- /dev/null
+++ b/src/Exception/InvalidPodcastMediaFileException.php
@@ -0,0 +1,21 @@
+fsRecordings;
}
+ public function getPodcastsFilesystem(): ExtendedFilesystemInterface
+ {
+ if (!isset($this->fsPodcasts)) {
+ $podcastsAdapter = $this->station->getPodcastsStorageLocation()->getStorageAdapter();
+ if ($podcastsAdapter instanceof LocalAdapterInterface) {
+ $this->fsPodcasts = new LocalFilesystem($podcastsAdapter);
+ } else {
+ $tempDir = $this->station->getRadioTempDir();
+ $this->fsPodcasts = new RemoteFilesystem($podcastsAdapter, $tempDir);
+ }
+ }
+
+ return $this->fsPodcasts;
+ }
+
public function getPlaylistsFilesystem(): LocalFilesystem
{
if (!isset($this->fsPlaylists)) {
diff --git a/src/Form/StationCloneForm.php b/src/Form/StationCloneForm.php
index d088bf3e8..1a34a41ca 100644
--- a/src/Form/StationCloneForm.php
+++ b/src/Form/StationCloneForm.php
@@ -201,6 +201,7 @@ class StationCloneForm extends StationForm
$this->em->persist($new_record->getMediaStorageLocation());
$this->em->persist($new_record->getRecordingsStorageLocation());
+ $this->em->persist($new_record->getPodcastsStorageLocation());
foreach ($new_record->getMounts() as $subrecord) {
$this->em->persist($subrecord);
diff --git a/src/Http/ServerRequest.php b/src/Http/ServerRequest.php
index ae6130af5..137e276fb 100644
--- a/src/Http/ServerRequest.php
+++ b/src/Http/ServerRequest.php
@@ -140,11 +140,13 @@ final class ServerRequest extends \Slim\Http\ServerRequest
}
if (!($object instanceof $class_name)) {
- throw new Exception\InvalidRequestAttribute(sprintf(
- 'Attribute "%s" must be of type "%s".',
- $attr,
- $class_name
- ));
+ throw new Exception\InvalidRequestAttribute(
+ sprintf(
+ 'Attribute "%s" must be of type "%s".',
+ $attr,
+ $class_name
+ )
+ );
}
return $object;
diff --git a/src/Media/MetadataService/GetId3MetadataService.php b/src/Media/MetadataService/GetId3MetadataService.php
index 6cfe6339b..75d86f52a 100644
--- a/src/Media/MetadataService/GetId3MetadataService.php
+++ b/src/Media/MetadataService/GetId3MetadataService.php
@@ -95,6 +95,8 @@ class GetId3MetadataService
$metadata->setArtwork($info['id3v2']['PIC'][0]['data']);
}
+ $metadata->setMimeType($info['mime_type']);
+
return $metadata;
}
diff --git a/src/Message/AddNewPodcastMediaMessage.php b/src/Message/AddNewPodcastMediaMessage.php
new file mode 100644
index 000000000..8d931f4e4
--- /dev/null
+++ b/src/Message/AddNewPodcastMediaMessage.php
@@ -0,0 +1,21 @@
+podcastMediaId;
+ }
+
+ public function getQueue(): string
+ {
+ return QueueManager::QUEUE_PODCAST_MEDIA;
+ }
+}
diff --git a/src/MessageQueue/QueueManager.php b/src/MessageQueue/QueueManager.php
index 9c9b88256..41b253983 100644
--- a/src/MessageQueue/QueueManager.php
+++ b/src/MessageQueue/QueueManager.php
@@ -17,6 +17,7 @@ class QueueManager implements SendersLocatorInterface
public const QUEUE_NORMAL_PRIORITY = 'normal_priority';
public const QUEUE_LOW_PRIORITY = 'low_priority';
public const QUEUE_MEDIA = 'media';
+ public const QUEUE_PODCAST_MEDIA = 'podcast_media';
protected string $workerName = 'app';
@@ -125,6 +126,7 @@ class QueueManager implements SendersLocatorInterface
self::QUEUE_NORMAL_PRIORITY,
self::QUEUE_LOW_PRIORITY,
self::QUEUE_MEDIA,
+ self::QUEUE_PODCAST_MEDIA,
];
}
}
diff --git a/src/Middleware/HandleMultipartJson.php b/src/Middleware/HandleMultipartJson.php
new file mode 100644
index 000000000..addc04ad1
--- /dev/null
+++ b/src/Middleware/HandleMultipartJson.php
@@ -0,0 +1,45 @@
+getParsedBody(),
+ static function ($value) {
+ return $value && 'null' !== $value;
+ }
+ );
+
+ if (1 === count($parsedBody)) {
+ $bodyField = current($parsedBody);
+ if (is_string($bodyField)) {
+ $parsedBody = json_decode($bodyField, true, 512, \JSON_THROW_ON_ERROR);
+
+ $request = $request->withParsedBody($parsedBody);
+ }
+ }
+
+ return $handler->handle($request);
+ }
+}
diff --git a/src/Middleware/RequirePublishedPodcastEpisodeMiddleware.php b/src/Middleware/RequirePublishedPodcastEpisodeMiddleware.php
new file mode 100644
index 000000000..73d19bc30
--- /dev/null
+++ b/src/Middleware/RequirePublishedPodcastEpisodeMiddleware.php
@@ -0,0 +1,105 @@
+getLoggedInUser($request);
+ $station = $request->getStation();
+
+ if ($user !== null) {
+ $acl = $request->getAcl();
+
+ if ($this->canUserManageStationPodcasts($user, $station, $acl)) {
+ return $handler->handle($request);
+ }
+ }
+
+ $podcastId = $this->getPodcastIdFromRequest($request);
+
+ if ($podcastId === null || !$this->checkPodcastHasPublishedEpisodes($station, $podcastId)) {
+ throw new PodcastNotFoundException();
+ }
+
+ $response = $handler->handle($request);
+
+ if ($response instanceof Response) {
+ $response = $response->withNoCache();
+ }
+
+ return $response;
+ }
+
+ protected function getLoggedInUser(ServerRequest $request): ?User
+ {
+ try {
+ return $request->getUser();
+ } catch (Exception $e) {
+ return null;
+ }
+ }
+
+ protected function canUserManageStationPodcasts(User $user, Station $station, Acl $acl): bool
+ {
+ return $acl->userAllowed($user, Acl::STATION_PODCASTS, $station->getId());
+ }
+
+ protected function getPodcastIdFromRequest(ServerRequest $request): ?string
+ {
+ $routeContext = RouteContext::fromRequest($request);
+ $routeArgs = $routeContext->getRoute()?->getArguments();
+
+ $podcastId = $routeArgs['id'] ?? null;
+
+ if ($podcastId === null) {
+ $podcastId = $routeArgs['podcast_id'];
+ }
+
+ return $podcastId;
+ }
+
+ protected function checkPodcastHasPublishedEpisodes(Station $station, string $podcastId): bool
+ {
+ $podcastId = explode('|', $podcastId)[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;
+ }
+}
diff --git a/src/Radio/Configuration.php b/src/Radio/Configuration.php
index 6b2dc694c..47b85189a 100644
--- a/src/Radio/Configuration.php
+++ b/src/Radio/Configuration.php
@@ -58,6 +58,7 @@ class Configuration
$this->em->persist($station);
$this->em->persist($station->getMediaStorageLocation());
$this->em->persist($station->getRecordingsStorageLocation());
+ $this->em->persist($station->getPodcastsStorageLocation());
$this->em->flush();
}
diff --git a/templates/frontend/public/podcast-episode.js.phtml b/templates/frontend/public/podcast-episode.js.phtml
new file mode 100644
index 000000000..e816a6591
--- /dev/null
+++ b/templates/frontend/public/podcast-episode.js.phtml
@@ -0,0 +1,3 @@
+$(function () {
+ $('[data-toggle="tooltip"]').tooltip();
+});
diff --git a/templates/frontend/public/podcast-episode.phtml b/templates/frontend/public/podcast-episode.phtml
new file mode 100644
index 000000000..9721927d7
--- /dev/null
+++ b/templates/frontend/public/podcast-episode.phtml
@@ -0,0 +1,119 @@
+layout('minimal', [
+ 'page_class' => 'podcasts station-' . $station->getShortName(),
+ 'title' => 'Podcasts - ' . $this->e($station->getName()),
+ 'hide_footer' => true,
+]);
+
+/** @var \App\Assets $assets */
+$assets->addInlineJs(
+ $this->fetch('frontend/public/podcast-episode.js', [])
+);
+
+$episodeAudioSrc = (string) $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());
+}
+
+$this->push('head');
+?>
+
+
+
+end();
+?>
+
+
+
+
+
+
=$this->e($podcast->getTitle())?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ =$publishedAt->format('d. M. Y')?>
+
+
+ getExplicit()) : ?>
+
+ =__('Explicit') ?>
+
+
+
+
+
+
+
=$this->e($episode->getTitle()) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
=$this->e($episode->getDescription()) ?>
+
+
+
+
+
+
+
diff --git a/templates/frontend/public/podcast-episodes.js.phtml b/templates/frontend/public/podcast-episodes.js.phtml
new file mode 100644
index 000000000..e816a6591
--- /dev/null
+++ b/templates/frontend/public/podcast-episodes.js.phtml
@@ -0,0 +1,3 @@
+$(function () {
+ $('[data-toggle="tooltip"]').tooltip();
+});
diff --git a/templates/frontend/public/podcast-episodes.phtml b/templates/frontend/public/podcast-episodes.phtml
new file mode 100644
index 000000000..aebced38f
--- /dev/null
+++ b/templates/frontend/public/podcast-episodes.phtml
@@ -0,0 +1,109 @@
+layout(
+ 'minimal',
+ [
+ 'page_class' => 'podcasts station-' . $station->getShortName(),
+ 'title' => 'Podcasts - ' . $this->e($station->getName()),
+ 'hide_footer' => true,
+ ]
+);
+
+/** @var \App\Assets $assets */
+$assets->addInlineJs(
+ $this->fetch('frontend/public/podcast-episodes.js', [])
+);
+
+$this->push('head');
+?>
+
+
+
+end();
+?>
+
+
+
+
+
+
=__('Episodes')?>
+
+
+
+
=$this->e($podcast->getTitle())?>
+
+
+
+
+
+
+
+ named('public:podcast:episode',
+ [
+ 'station_id' => $station->getId(),
+ 'podcast_id' => $podcast->getId(),
+ 'episode_id' => $episode->getId(),
+ ]
+ ) ?>
+
+
+
+
+
+
+
+
=$this->e($episode->getTitle())?>
+
+
=$this->e($episode->getDescription())?>
+
+ getExplicit()) : ?>
+
+ warning
+ =__('Contains explicit content') ?>
+
+
+
+
+ getCreatedAt()); ?>
+ getPublishAt() !== null) : ?>
+ getPublishAt()); ?>
+
+ =$publishedAt->format('d. M. Y') ?>
+
+
+
=__('View Details') ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/frontend/public/podcasts.phtml b/templates/frontend/public/podcasts.phtml
new file mode 100644
index 000000000..febb83c13
--- /dev/null
+++ b/templates/frontend/public/podcasts.phtml
@@ -0,0 +1,106 @@
+layout('minimal', [
+ 'page_class' => 'podcasts station-' . $station->getShortName(),
+ 'title' => 'Podcasts - ' . $this->e($station->getName()),
+ 'hide_footer' => true,
+]);
+
+?>
+
+
+
+
+
=$this->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())?>
+
+
=$this->e($podcast->getDescription())?>
+
+
+ =__('Language')?>: =strtoupper(
+ $podcast->getLanguage()
+ )?>
+
+ =__('Categories')?>: =implode(
+ $podcast->getCategories()->map(
+ function ($category) {
+ $title = $category->getTitle();
+ $subtitle = $category->getSubTitle();
+
+ return (!empty($subtitle))
+ ? $title . ' - ' . $subtitle
+ : $title;
+ }
+ )->getValues()
+ );?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
=__('No entries found.') ?>
+
+
+
+
+
+
+
+
diff --git a/templates/stations/podcasts/index.phtml b/templates/stations/podcasts/index.phtml
new file mode 100644
index 000000000..b8c7596d3
--- /dev/null
+++ b/templates/stations/podcasts/index.phtml
@@ -0,0 +1,20 @@
+layout('main', [
+ 'title' => __('Podcasts'),
+ 'manual' => true,
+]);
+
+$props = [
+ 'listUrl' => $router->fromHere('api:stations:podcasts'),
+ 'stationUrl' => $router->fromHere('stations:index:index', [$stationId]),
+ 'locale' => substr($customization->getLocale(), 0, 2),
+ 'stationTimeZone' => $stationTz,
+ 'languageOptions' => $languageOptions,
+ 'categoriesOptions' => $categoriesOptions,
+];
+
+/** @var \App\Assets $assets */
+$assets->addVueRender('Vue_StationsPodcasts', '#station-podcasts', $props);
+?>
+
+
diff --git a/templates/stations/profile/index.phtml b/templates/stations/profile/index.phtml
index b66130994..3fe2fa581 100644
--- a/templates/stations/profile/index.phtml
+++ b/templates/stations/profile/index.phtml
@@ -66,6 +66,12 @@ $props = [
[],
true
),
+ 'publicPodcastsUri' => (string)$router->named(
+ 'public:podcasts',
+ ['station_id' => $station->getShortName()],
+ [],
+ true
+ ),
'publicOnDemandEmbedUri' => (string)$router->named(
'public:ondemand',
['station_id' => $station->getShortName(), 'embed' => 'embed'],
diff --git a/util/openapi.php b/util/openapi.php
index a2d5dcd30..ef4cebab1 100644
--- a/util/openapi.php
+++ b/util/openapi.php
@@ -55,6 +55,7 @@ use OpenApi\Annotations as OA;
* @OA\Tag(name="Stations: Media")
* @OA\Tag(name="Stations: Mount Points")
* @OA\Tag(name="Stations: Playlists")
+ * @OA\Tag(name="Stations: Podcasts")
* @OA\Tag(name="Stations: Queue")
* @OA\Tag(name="Stations: Remote Relays")
* @OA\Tag(name="Stations: Streamers/DJs")
diff --git a/web/static/api/openapi.yml b/web/static/api/openapi.yml
index 8f730bff5..f30697298 100644
--- a/web/static/api/openapi.yml
+++ b/web/static/api/openapi.yml
@@ -1277,6 +1277,301 @@ paths:
security:
-
api_key: []
+ '/station/{station_id}/podcast/{podcast_id}/episodes':
+ get:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'List all current episodes for a given podcast ID.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ -
+ name: podcast_id
+ in: path
+ description: 'Podcast ID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Api_PodcastEpisode'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
+ post:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'Create a new podcast episode.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ -
+ name: podcast_id
+ in: path
+ description: 'Podcast ID'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_PodcastEpisode'
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_PodcastEpisode'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
+ '/station/{station_id}/podcast/{podcast_id}/episode/{id}':
+ get:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'Retrieve details for a single podcast episode.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ -
+ name: podcast_id
+ in: path
+ description: 'Podcast ID'
+ required: true
+ schema:
+ type: string
+ -
+ name: id
+ in: path
+ description: 'Podcast Episode ID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_PodcastEpisode'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
+ put:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'Update details of a single podcast episode.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ -
+ name: podcast_id
+ in: path
+ description: 'Podcast ID'
+ required: true
+ schema:
+ type: string
+ -
+ name: id
+ in: path
+ description: 'Podcast Episode ID'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_PodcastEpisode'
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_Status'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
+ delete:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'Delete a single podcast episode.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ -
+ name: podcast_id
+ in: path
+ description: 'Podcast ID'
+ required: true
+ schema:
+ type: string
+ -
+ name: id
+ in: path
+ description: 'Podcast Episode ID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_Status'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
+ '/station/{station_id}/podcasts':
+ get:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'List all current podcasts.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Api_Podcast'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
+ post:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'Create a new podcast.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_Podcast'
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_Podcast'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
+ '/station/{station_id}/podcast/{id}':
+ get:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'Retrieve details for a single podcast.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ -
+ name: id
+ in: path
+ description: 'Podcast ID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_Podcast'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
+ put:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'Update details of a single podcast.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ -
+ name: id
+ in: path
+ description: 'Podcast ID'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_Podcast'
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_Status'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
+ delete:
+ tags:
+ - 'Stations: Podcasts'
+ description: 'Delete a single podcast.'
+ parameters:
+ -
+ $ref: '#/components/parameters/station_id_required'
+ -
+ name: id
+ in: path
+ description: 'Podcast ID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Api_Status'
+ '403':
+ description: 'Access denied'
+ security:
+ -
+ api_key: []
'/station/{station_id}/queue':
get:
tags:
@@ -2103,11 +2398,11 @@ components:
connected_on:
description: 'UNIX timestamp that the user first connected.'
type: integer
- example: 1619569512
+ example: 1621831102
connected_until:
description: 'UNIX timestamp that the user disconnected (or the latest timestamp if they are still connected).'
type: integer
- example: 1619569512
+ example: 1621831102
connected_time:
description: 'Number of seconds that the user has been connected.'
type: integer
@@ -2210,6 +2505,94 @@ components:
example: '1591548318'
nullable: true
type: object
+ Api_Podcast:
+ type: object
+ allOf:
+ -
+ $ref: '#/components/schemas/HasLinks'
+ -
+ properties:
+ id:
+ type: string
+ nullable: true
+ storage_location_id:
+ type: integer
+ nullable: true
+ title:
+ type: string
+ nullable: true
+ link:
+ type: string
+ nullable: true
+ description:
+ type: string
+ nullable: true
+ language:
+ type: string
+ nullable: true
+ has_custom_art:
+ type: boolean
+ art:
+ type: string
+ nullable: true
+ art_updated_at:
+ type: integer
+ categories:
+ items:
+ type: string
+ episodes:
+ items:
+ type: string
+ Api_PodcastEpisode:
+ type: object
+ allOf:
+ -
+ $ref: '#/components/schemas/HasLinks'
+ -
+ properties:
+ id:
+ type: string
+ nullable: true
+ title:
+ type: string
+ nullable: true
+ description:
+ type: string
+ nullable: true
+ explicit:
+ type: boolean
+ publish_at:
+ type: integer
+ nullable: true
+ has_media:
+ type: boolean
+ media:
+ $ref: '#/components/schemas/Api_PodcastMedia'
+ has_custom_art:
+ type: boolean
+ art:
+ type: string
+ nullable: true
+ art_updated_at:
+ type: integer
+ Api_PodcastMedia:
+ properties:
+ id:
+ type: string
+ nullable: true
+ original_name:
+ type: string
+ nullable: true
+ length:
+ type: number
+ format: float
+ length_text:
+ type: string
+ nullable: true
+ path:
+ type: string
+ nullable: true
+ type: object
Api_Song:
properties:
id:
@@ -2257,7 +2640,7 @@ components:
played_at:
description: 'UNIX timestamp when playback started.'
type: integer
- example: 1619569512
+ example: 1621831102
duration:
description: 'Duration of the song in seconds'
type: integer
@@ -2393,7 +2776,7 @@ components:
cued_at:
description: 'UNIX timestamp when playback is expected to start.'
type: integer
- example: 1619569512
+ example: 1621831102
duration:
description: 'Duration of the song in seconds'
type: integer
@@ -2482,7 +2865,7 @@ components:
start_timestamp:
description: 'The start time of the schedule entry, in UNIX format.'
type: integer
- example: 1619569512
+ example: 1621831102
start:
description: 'The start time of the schedule entry, in ISO 8601 format.'
type: string
@@ -2490,7 +2873,7 @@ components:
end_timestamp:
description: 'The end time of the schedule entry, in UNIX format.'
type: integer
- example: 1619569512
+ example: 1621831102
end:
description: 'The start time of the schedule entry, in ISO 8601 format.'
type: string
@@ -2530,7 +2913,7 @@ components:
timestamp:
description: 'The current UNIX timestamp'
type: integer
- example: 1619569512
+ example: 1621831102
type: object
Api_Time:
properties:
@@ -2602,10 +2985,10 @@ components:
example: true
created_at:
type: integer
- example: 1619569512
+ example: 1621831102
updated_at:
type: integer
- example: 1619569512
+ example: 1621831102
type: object
Role:
properties:
@@ -2672,7 +3055,7 @@ components:
update_last_run:
description: 'The UNIX timestamp when updates were last checked.'
type: integer
- example: 1619569512
+ example: 1621831102
public_theme:
description: 'Base Theme for Public Pages'
type: string
@@ -2749,7 +3132,7 @@ components:
backup_last_run:
description: 'The UNIX timestamp when automated backup was last run.'
type: integer
- example: 1619569512
+ example: 1621831102
backup_last_result:
description: 'The result of the latest automated backup task.'
type: string
@@ -2763,26 +3146,26 @@ components:
setup_complete_time:
description: 'The UNIX timestamp when setup was last completed.'
type: integer
- example: 1619569512
+ example: 1621831102
nowplaying:
description: 'The current cached now playing data.'
example: ''
sync_nowplaying_last_run:
description: 'The UNIX timestamp when the now playing sync task was last run.'
type: integer
- example: 1619569512
+ example: 1621831102
sync_short_last_run:
description: 'The UNIX timestamp when the 60-second "short" sync task was last run.'
type: integer
- example: 1619569512
+ example: 1621831102
sync_medium_last_run:
description: 'The UNIX timestamp when the 5-minute "medium" sync task was last run.'
type: integer
- example: 1619569512
+ example: 1621831102
sync_long_last_run:
description: 'The UNIX timestamp when the 1-hour "long" sync task was last run.'
type: integer
- example: 1619569512
+ example: 1621831102
external_ip:
description: 'This installation''s external IP.'
type: string
@@ -2796,8 +3179,8 @@ components:
geolite_last_run:
description: 'The UNIX timestamp when the Maxmind Geolite was last downloaded.'
type: integer
- example: 1619569512
- enableAdvancedFeatures:
+ example: 1621831102
+ enable_advanced_features:
description: 'Whether to enable "advanced" functionality in the system that is intended for power users.'
type: boolean
example: false
@@ -3008,7 +3391,7 @@ components:
mtime:
description: 'The UNIX timestamp when the database was last modified.'
type: integer
- example: 1619569512
+ example: 1621831102
nullable: true
amplify:
description: 'The amount of amplification (in dB) to be applied to the radio source;'
@@ -3049,7 +3432,7 @@ components:
art_updated_at:
description: 'The latest time (UNIX timestamp) when album art was updated.'
type: integer
- example: 1619569512
+ example: 1621831102
playlists:
items: { }
StationMount:
@@ -3298,7 +3681,7 @@ components:
example: false
reactivate_at:
type: integer
- example: 1619569512
+ example: 1621831102
nullable: true
schedule_items:
items: { }
@@ -3380,10 +3763,10 @@ components:
nullable: true
created_at:
type: integer
- example: 1619569512
+ example: 1621831102
updated_at:
type: integer
- example: 1619569512
+ example: 1621831102
roles:
items: { }
type: object
@@ -3427,6 +3810,8 @@ tags:
name: 'Stations: Mount Points'
-
name: 'Stations: Playlists'
+ -
+ name: 'Stations: Podcasts'
-
name: 'Stations: Queue'
-
diff --git a/web/static/img/generic_song.jpg b/web/static/img/generic_song.jpg
index 88c0c40ec..d934154f5 100755
Binary files a/web/static/img/generic_song.jpg and b/web/static/img/generic_song.jpg differ