Implement Podcasting Support

Co-authored-by: Buster "Silver Eagle" Neece <buster@busterneece.com>
Co-authored-by: Mitch <Mitchellfrith1996@gmail.com>
This commit is contained in:
Vaalyn 2021-05-25 06:29:07 +02:00 committed by GitHub
parent 89410971b4
commit 1a04f9791f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 5894 additions and 45 deletions

View File

@ -5,6 +5,11 @@ release channel, you can take advantage of these new features and fixes.
## New Features/Changes
- **Podcast Management (Beta):** You can now upload and manage podcasts directly via the AzuraCast web interface. Via
this interface, you can create and manage individual podcast episodes and associate them with uploaded media (which
can be managed in an interface similar to the Media Manager). Podcasts have their own automatically generated public
pages and RSS feeds that are compatible with many major podcast aggregation services.
- **Automatic Theme Selection:** If you haven't set a default theme for either your user account or the AzuraCast public
pages, the theme will automatically be determined by the user's browser based on their OS's theme preference (dark or
light). You can override this by selecting a default theme in the "Branding" settings, or reset to using browser

View File

@ -15,7 +15,10 @@ INIT_DEMO_API_KEY=
INIT_ADMIN_EMAIL=
INIT_ADMIN_PASSWORD=
INIT_ADMIN_API_KEY=
INIT_MUSIC_PATH=/var/azuracast/www/util/fixtures/init_music
INIT_PODCASTS_PATH=/var/azuracast/www/util/fixtures/init_podcasts
INIT_GEOLITE_LICENSE_KEY=
#

View File

@ -41,6 +41,7 @@
"league/mime-type-detection": "^1.7",
"league/plates": "^3.1",
"lstrojny/fxmlrpc": "dev-master",
"marcw/rss-writer": "^0.4.0",
"matomo/device-detector": "^4.0",
"mezzio/mezzio-session": "^1.3",
"mezzio/mezzio-session-cache": "^1.4",
@ -66,6 +67,7 @@
"symfony/console": "^5",
"symfony/event-dispatcher": "^5",
"symfony/finder": "^5",
"symfony/intl": "^5.2",
"symfony/lock": "^5.1",
"symfony/mailer": "^5.2",
"symfony/messenger": "^5",

233
composer.lock generated
View File

@ -3572,6 +3572,64 @@
},
"time": "2021-05-21T15:11:33+00:00"
},
{
"name": "marcw/rss-writer",
"version": "0.4.0",
"source": {
"type": "git",
"url": "https://github.com/marcw/rss-writer.git",
"reference": "4bbd63aea62246fe43bec589a1e8bdda2f4ef219"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/marcw/rss-writer/zipball/4bbd63aea62246fe43bec589a1e8bdda2f4ef219",
"reference": "4bbd63aea62246fe43bec589a1e8bdda2f4ef219",
"shasum": ""
},
"require": {
"ext-xmlwriter": "*"
},
"require-dev": {
"phpunit/phpunit": "^5.4",
"symfony/debug": "^3.1",
"symfony/http-foundation": "^3.1",
"symfony/validator": "^3.1",
"symfony/var-dumper": "^3.1"
},
"suggest": {
"symfony/http-foundation": "Enable streaming RSS response",
"symfony/validator": ""
},
"type": "library",
"autoload": {
"psr-4": {
"MarcW\\RssWriter\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Marc Weistroff",
"email": "marc@weistroff.net"
}
],
"description": "A simple yet powerful RSS2 feed writer with RSS extensions support (like iTunes podcast tags)",
"keywords": [
"feed",
"podcast",
"podcasting",
"rss",
"rss2"
],
"support": {
"issues": "https://github.com/marcw/rss-writer/issues",
"source": "https://github.com/marcw/rss-writer/tree/master"
},
"time": "2017-04-01T11:53:47+00:00"
},
{
"name": "matomo/device-detector",
"version": "4.2.2",
@ -6901,6 +6959,94 @@
],
"time": "2021-02-15T18:55:04+00:00"
},
{
"name": "symfony/intl",
"version": "v5.2.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/intl.git",
"reference": "6d40be5e4331041aa14add5633986d95667ae624"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/intl/zipball/6d40be5e4331041aa14add5633986d95667ae624",
"reference": "6d40be5e4331041aa14add5633986d95667ae624",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/polyfill-intl-icu": "~1.0",
"symfony/polyfill-php80": "^1.15"
},
"require-dev": {
"symfony/filesystem": "^4.4|^5.0"
},
"suggest": {
"ext-intl": "to use the component with locales other than \"en\""
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Intl\\": ""
},
"classmap": [
"Resources/stubs"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Bernhard Schussek",
"email": "bschussek@gmail.com"
},
{
"name": "Eriksen Costa",
"email": "eriksen.costa@infranology.com.br"
},
{
"name": "Igor Wiedler",
"email": "igor@wiedler.ch"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a PHP replacement layer for the C intl extension that includes additional data from the ICU library",
"homepage": "https://symfony.com",
"keywords": [
"i18n",
"icu",
"internationalization",
"intl",
"l10n",
"localization"
],
"support": {
"source": "https://github.com/symfony/intl/tree/v5.2.7"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2021-04-24T14:39:13+00:00"
},
{
"name": "symfony/lock",
"version": "v5.2.6",
@ -7312,6 +7458,93 @@
],
"time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-intl-icu",
"version": "v1.22.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
"reference": "af1842919c7e7364aaaa2798b29839e3ba168588"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/af1842919c7e7364aaaa2798b29839e3ba168588",
"reference": "af1842919c7e7364aaaa2798b29839e3ba168588",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"suggest": {
"ext-intl": "For best performance and support of other locales than \"en\""
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
"url": "https://github.com/symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Icu\\": ""
},
"classmap": [
"Resources/stubs"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's ICU-related data and classes",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"icu",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.22.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2021-01-22T09:19:47+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.22.1",

View File

@ -530,6 +530,18 @@ return [
// Auto-managed by Assets
],
'Vue_StationsPodcasts' => [
'order' => 10,
'require' => ['vue-component-common', 'bootstrap-vue', 'fancybox', 'moment_base', 'moment_timezone'],
// Auto-managed by Assets
],
'Vue_StationsPodcastEpisodes' => [
'order' => 10,
'require' => ['vue-component-common', 'bootstrap-vue', 'fancybox', 'moment_base', 'moment_timezone'],
// Auto-managed by Assets
],
'Vue_StationsProfile' => [
'order' => 10,
'require' => ['vue-component-common', 'bootstrap-vue', 'moment', 'fancybox'],

View File

@ -70,6 +70,12 @@ return function (App\Event\BuildStationMenu $e) {
'visible' => $backend->supportsMedia(),
'permission' => Acl::STATION_MEDIA,
],
'podcasts' => [
'label' => __('Podcasts (Beta)'),
'icon' => 'cast',
'url' => $router->fromHere('stations:podcasts:index'),
'permission' => Acl::STATION_PODCASTS,
],
'streamers' => [
'label' => __('Streamer/DJ Accounts'),
'icon' => 'mic',

View File

@ -9,6 +9,9 @@ return [
Message\AddNewMediaMessage::class => Task\CheckMediaTask::class,
Message\ReprocessMediaMessage::class => Task\CheckMediaTask::class,
Message\AddNewPodcastMediaMessage::class => Task\CheckPodcastMediaTask::class,
Message\ReprocessPodcastMediaMessage::class => Task\CheckPodcastMediaTask::class,
Message\WritePlaylistFileMessage::class => Liquidsoap\ConfigWriter::class,
Message\UpdateNowPlayingMessage::class => Task\NowPlayingTask::class,

View File

@ -267,6 +267,97 @@ return function (App $app) {
$group->delete('/art/{media_id:[a-zA-Z0-9]+}', Controller\Api\Stations\Art\DeleteArtAction::class)
->add(new Middleware\Permissions(Acl::STATION_MEDIA, true));
// Public and private podcast pages
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
->setName('api:stations:podcast');
$group->get(
'/art',
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
)->setName('api:stations: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(
'/art',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
)->setName('api:stations:podcast:episode:art');
$group->get(
'/download',
Controller\Api\Stations\Podcasts\Episodes\DownloadAction::class
)->setName('api:stations:podcast:episode:download');
}
);
}
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
// Private-only podcast pages
$group->group(
'/podcasts',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Stations\PodcastsController::class . ':listAction')
->setName('api:stations:podcasts');
$group->post('', Controller\Api\Stations\PodcastsController::class . ':createAction')
->add(new Middleware\HandleMultipartJson());
}
)->add(new Middleware\Permissions(Acl::STATION_PODCASTS, true));
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
$group->post('', Controller\Api\Stations\PodcastsController::class . ':editAction')
->add(new Middleware\HandleMultipartJson());
$group->delete('', Controller\Api\Stations\PodcastsController::class . ':deleteAction');
$group->delete(
'/art',
Controller\Api\Stations\Podcasts\Art\DeleteArtAction::class
)->setName('api:stations:podcast:art-internal');
$group->post(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':createAction'
)->add(new Middleware\HandleMultipartJson());
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->post(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':editAction'
)->add(new Middleware\HandleMultipartJson());
$group->delete(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':deleteAction'
);
$group->delete(
'/art',
Controller\Api\Stations\Podcasts\Episodes\Art\DeleteArtAction::class
)->setName('api:stations:podcast:episode:art-internal');
}
);
}
)->add(new Middleware\Permissions(Acl::STATION_PODCASTS, true));
$station_api_endpoints = [
['file', 'files', Controller\Api\Stations\FilesController::class, Acl::STATION_MEDIA],
['mount', 'mounts', Controller\Api\Stations\MountsController::class, Acl::STATION_MOUNTS],

View File

@ -26,6 +26,18 @@ return function (App $app) {
$group->get('/ondemand[/{embed:embed}]', Controller\Frontend\PublicPages\OnDemandAction::class)
->setName('public:ondemand');
$group->get('/podcasts', Controller\Frontend\PublicPages\PodcastsController::class)
->setName('public:podcasts');
$group->get('/podcast/{podcast_id}/episodes', Controller\Frontend\PublicPages\PodcastEpisodesController::class)
->setName('public:podcast:episodes');
$group->get('/podcast/{podcast_id}/episode/{episode_id}', Controller\Frontend\PublicPages\PodcastEpisodeController::class)
->setName('public:podcast:episode');
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedController::class)
->setName('public:podcast:feed');
}
)
->add(Middleware\GetStation::class)

View File

@ -55,6 +55,10 @@ return function (App $app) {
->setName('stations:playlists:index')
->add(new Middleware\Permissions(Acl::STATION_MEDIA, true));
$group->get('/podcasts', Controller\Stations\PodcastsAction::class)
->setName('stations:podcasts:index')
->add(new Middleware\Permissions(Acl::STATION_PODCASTS, true));
$group->group(
'/mounts',
function (RouteCollectorProxy $group) {

View File

@ -7,6 +7,7 @@
<b-tabs pills card lazy>
<b-tab :active="activeType === 'station_media'" @click="setType('station_media')" :title="langStationMediaTab" no-body></b-tab>
<b-tab :active="activeType === 'station_recordings'" @click="setType('station_recordings')" :title="langStationRecordingsTab" no-body></b-tab>
<b-tab :active="activeType === 'station_podcasts'" @click="setType('station_podcasts')" :title="langStationPodcastsTab" no-body></b-tab>
<b-tab :active="activeType === 'backup'" @click="setType('backup')" :title="langBackupsTab" no-body></b-tab>
</b-tabs>
@ -72,6 +73,9 @@ export default {
langStationRecordingsTab () {
return this.$gettext('Station Recordings');
},
langStationPodcastsTab () {
return this.$gettext('Station Podcasts');
},
langBackupsTab () {
return this.$gettext('Backups');
},

View File

@ -0,0 +1,24 @@
<template>
<a :href="src" class="album-art" target="_blank" data-fancybox="gallery">
<img class="album_art" :src="src" :style="{ width: this.width+'px' }">
</a>
</template>
<style lang="scss">
.album-art img {
height: auto;
border-radius: 5px;
}
</style>
<script>
export default {
props: {
src: String,
width: {
type: Number,
default: 40
}
}
};
</script>

View File

@ -60,11 +60,11 @@
:no-provider-filtering="handleClientSide"
tbody-tr-class="align-middle" thead-tr-class="align-middle" selected-variant=""
:filter="filter" @filtered="onFiltered" @refreshed="onRefreshed">
<template v-slot:head(selected)="data">
<template #head(selected)="data">
<b-form-checkbox :aria-label="langSelectAll" :checked="allSelected"
@change="toggleSelected"></b-form-checkbox>
</template>
<template v-slot:cell(selected)="{ rowSelected }">
<template #cell(selected)="{ rowSelected }">
<div class="text-muted">
<template v-if="rowSelected">
<span class="sr-only">{{ langDeselectRow }}</span>
@ -76,7 +76,7 @@
</template>
</div>
</template>
<template v-slot:table-busy>
<template #table-busy>
<div role="alert" aria-live="polite">
<div class="text-center my-2">
<div class="progress-circular progress-circular-primary mx-auto mb-3">
@ -122,6 +122,13 @@ div.datatable-toolbar-bottom {
padding: 0;
}
table.b-table {
td.shrink {
width: 0.1%;
white-space: nowrap;
}
}
table.b-table-selectable {
thead tr th:nth-child(1),
tbody tr td:nth-child(1),

View File

@ -16,10 +16,7 @@
:request-config="requestConfig">
<template v-slot:cell(name)="row">
<div :class="{ is_dir: row.item.is_dir, is_file: !row.item.is_dir }">
<a :href="row.item.media_art" class="album-art float-right pl-3" target="_blank"
v-if="row.item.media_art" data-fancybox="gallery">
<img class="media_manager_album_art" :alt="langAlbumArt" :src="row.item.media_art">
</a>
<album-art v-if="row.item.media_art" :src="row.item.media_art" class="float-right pl-3"></album-art>
<template v-if="row.item.media_is_playable">
<a class="file-icon btn-audio has-listener" href="#" :data-url="row.item.media_links_play"
@ -107,14 +104,6 @@
</div>
</template>
<style lang="scss">
img.media_manager_album_art {
width: 40px;
height: auto;
border-radius: 5px;
}
</style>
<script>
import DataTable from '../Common/DataTable';
import MediaToolbar from './Media/MediaToolbar';
@ -127,9 +116,11 @@ import EditModal from './Media/EditModal';
import formatFileSize from '../Function/FormatFileSize.js';
import _ from 'lodash';
import Icon from '../Common/Icon';
import AlbumArt from '../Common/AlbumArt';
export default {
components: {
AlbumArt,
Icon,
EditModal,
RenameModal,
@ -229,7 +220,7 @@ export default {
selectable: true,
visible: true
},
{ key: 'commands', label: this.$gettext('Actions'), sortable: false }
{ key: 'commands', label: this.$gettext('Actions'), sortable: false, class: 'shrink' }
);
return {

View File

@ -0,0 +1,30 @@
<template>
<div>
<episodes-view v-if="activePodcast" v-bind="$props" :podcast="activePodcast" @clear-podcast="onClearPodcast"></episodes-view>
<list-view v-else v-bind="$props" @select-podcast="onSelectPodcast"></list-view>
</div>
</template>
<script>
import EpisodesView, { episodeViewProps } from './Podcasts/EpisodesView';
import ListView, { listViewProps } from './Podcasts/ListView';
export default {
name: 'StationPodcasts',
components: { ListView, EpisodesView },
mixins: [episodeViewProps, listViewProps],
data () {
return {
activePodcast: null
};
},
methods: {
onSelectPodcast (podcast) {
this.activePodcast = podcast;
},
onClearPodcast () {
this.activePodcast = null;
}
}
};
</script>

View File

@ -0,0 +1,54 @@
<template>
<b-tab :title="langTitle">
<b-form-group>
<b-row>
<b-form-group class="col-md-8" label-for="artwork_file">
<template #label>
<translate key="artwork_file">Select PNG/JPG artwork file</translate>
</template>
<template #description>
<translate key="artwork_file_desc">Artwork must be a minimum size of 1400 x 1400 pixels and a maximum size of 3000 x 3000 pixels for Apple Podcasts.</translate>
</template>
<b-form-file id="artwork_file" accept="image/jpeg, image/png" v-model="form.artwork_file.$model" @input="updatePreviewArtwork"></b-form-file>
</b-form-group>
<b-form-group class="col-md-4" v-if="src">
<b-img fluid center :src="src" aria-hidden="true"></b-img>
</b-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
export default {
name: 'PodcastCommonArtwork',
props: {
form: Object,
artworkSrc: String
},
data () {
return {
src: this.artworkSrc
};
},
computed: {
langTitle () {
return this.$gettext('Artwork');
}
},
methods: {
updatePreviewArtwork (file) {
if (!(file instanceof File)) {
return;
}
let fileReader = new FileReader();
fileReader.addEventListener('load', () => {
this.src = fileReader.result;
}, false);
fileReader.readAsDataURL(file);
}
}
};
</script>

View File

@ -0,0 +1,236 @@
<template>
<b-modal size="lg" id="edit_modal" ref="modal" :title="langTitle" :busy="loading">
<b-spinner v-if="loading">
</b-spinner>
<b-form class="form" v-else @submit.prevent="doSubmit">
<b-tabs content-class="mt-3">
<episode-form-basic-info :form="$v.form"></episode-form-basic-info>
<episode-form-media :form="$v.files" :has-media="record.has_media" :media="record.media" :download-url="record.links.download"></episode-form-media>
<podcast-common-artwork :form="$v.files" :artwork-src="record.art"></podcast-common-artwork>
</b-tabs>
<invisible-submit-button/>
</b-form>
<template #modal-footer>
<b-button variant="default" type="button" @click="close">
<translate key="lang_btn_close">Close</translate>
</b-button>
<template v-if="record.has_custom_art">
<b-button variant="danger" type="button" @click="clearArtwork(record.links.art)">
<translate key="lang_btn_clear_artwork">Clear Art</translate>
</b-button>
</template>
<b-button variant="primary" type="submit" @click="doSubmit" :disabled="$v.form.$invalid">
<translate key="lang_btn_save_changes">Save Changes</translate>
</b-button>
</template>
</b-modal>
</template>
<script>
import axios from 'axios';
import { validationMixin } from 'vuelidate';
import required from 'vuelidate/src/validators/required';
import InvisibleSubmitButton from '../../Common/InvisibleSubmitButton';
import EpisodeFormBasicInfo from './EpisodeForm/BasicInfo';
import PodcastCommonArtwork from './Common/Artwork';
import EpisodeFormMedia from './EpisodeForm/Media';
export default {
name: 'EditModal',
components: { EpisodeFormMedia, PodcastCommonArtwork, EpisodeFormBasicInfo, InvisibleSubmitButton },
mixins: [validationMixin],
props: {
createUrl: String,
stationTimeZone: String,
locale: String,
podcastId: String
},
data () {
return {
loading: true,
editUrl: null,
record: {
has_custom_art: false,
art: null,
has_media: false,
media: null,
links: {}
},
form: {},
files: {}
};
},
computed: {
langTitle () {
return this.isEditMode
? this.$gettext('Edit Episode')
: this.$gettext('Add Episode');
},
isEditMode () {
return this.editUrl !== null;
}
},
validations: {
form: {
'title': { required },
'link': {},
'description': { required },
'publish_date': {},
'publish_time': {},
'explicit': {}
},
files: {
'artwork_file': {},
'media_file': {}
}
},
methods: {
resetForm () {
this.editUrl = null;
this.record = {
has_custom_art: false,
art: null,
has_media: false,
media: null,
links: {}
};
this.form = {
'title': '',
'link': '',
'description': '',
'publish_date': '',
'publish_time': '',
'explicit': false
};
this.files = {
'artwork_file': null,
'media_file': null
};
},
create () {
this.resetForm();
this.loading = false;
this.$refs.modal.show();
},
edit (recordUrl) {
this.resetForm();
this.loading = true;
this.editUrl = recordUrl;
this.$refs.modal.show();
axios.get(this.editUrl).then((resp) => {
let d = resp.data;
let publishDate = '';
let publishTime = '';
if (d.publishAt !== null) {
let publishDateTime = moment.unix(d.publishAt);
publishDate = publishDateTime.format('YYYY-MM-DD');
publishTime = publishDateTime.format('hh:mm');
}
this.record = d;
this.form = {
'title': d.title,
'link': d.link,
'description': d.description,
'publish_date': publishDate,
'publish_time': publishTime,
'explicit': d.explicit
};
this.loading = false;
}).catch((err) => {
console.log(err);
this.close();
});
},
doSubmit () {
this.$v.$touch();
if (this.$v.$anyError) {
return;
}
let modifiedForm = this.form;
if (modifiedForm.publish_date.length > 0 && modifiedForm.publish_time.length > 0) {
let publishDateTimeString = modifiedForm.publish_date + ' ' + modifiedForm.publish_time;
let publishDateTime = moment(publishDateTimeString);
modifiedForm.publish_at = publishDateTime.unix();
}
let formData = new FormData();
formData.append('body', JSON.stringify(modifiedForm));
Object.entries(this.files).forEach(([key, value]) => {
if (null !== value) {
formData.append(key, value);
}
});
axios({
method: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
}).then((resp) => {
let notifyMessage = this.$gettext('Changes saved.');
notify('<b>' + notifyMessage + '</b>', 'success', false);
this.$emit('relist');
this.close();
}).catch((err) => {
console.error(err);
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
notify('<b>' + notifyMessage + '</b>', 'danger', false);
this.$emit('relist');
this.close();
});
},
clearArtwork (url) {
let buttonText = this.$gettext('Remove Artwork');
let buttonConfirmText = this.$gettext('Delete episode artwork?');
Swal.fire({
title: buttonConfirmText,
confirmButtonText: buttonText,
confirmButtonColor: '#e64942',
showCancelButton: true,
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.$emit('relist');
this.close();
}).catch((err) => {
console.error(err);
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger');
}
});
}
});
},
close () {
this.loading = false;
this.resetForm();
this.$v.$reset();
this.$refs.modal.hide();
}
}
};
</script>

View File

@ -0,0 +1,86 @@
<template>
<b-tab :title="langTitle" active>
<b-form-group>
<b-row>
<b-form-group class="col-md-6" label-for="form_edit_title">
<template #label>
<translate key="lang_form_edit_title">Episode</translate>
</template>
<b-form-input id="form_edit_title" type="text" v-model="form.title.$model"
:state="form.title.$dirty ? !form.title.$error : null"></b-form-input>
<b-form-invalid-feedback>
<translate key="lang_error_required">This field is required.</translate>
</b-form-invalid-feedback>
</b-form-group>
<b-form-group class="col-md-6" label-for="form_edit_link">
<template #label>
<translate key="lang_form_edit_link">Website</translate>
</template>
<template #description>
<translate key="lang_form_edit_link_desc">Typically a website with content about the episode.</translate>
</template>
<b-form-input id="form_edit_link" type="text" v-model="form.link.$model"
:state="form.link.$dirty ? !form.link.$error : null"></b-form-input>
</b-form-group>
<b-form-group class="col-md-12" label-for="form_edit_description">
<template #label>
<translate key="lang_form_edit_description">Description</translate>
</template>
<template #description>
<translate key="lang_form_edit_description_desc">The description of the episode. The typical maximum amount of text allowed for this is 4000 characters.</translate>
</template>
<b-form-textarea id="form_edit_description" v-model="form.description.$model"
:state="form.description.$dirty ? !form.description.$error : null"></b-form-textarea>
</b-form-group>
<b-form-group class="col-md-6" label-for="form_edit_publish_date">
<template #label>
<translate key="lang_form_edit_publish_date">Publish Date</translate>
</template>
<template #description>
<translate key="lang_form_edit_publish_date_desc">The date when the episode should be published.</translate>
</template>
<b-form-datepicker id="form_edit_publish_date" v-model="form.publish_date.$model"
:state="form.publish_date.$dirty ? !form.publish_date.$error : null" :locale="locale"></b-form-datepicker>
</b-form-group>
<b-form-group class="col-md-6" label-for="form_edit_publish_time">
<template #label>
<translate key="lang_form_edit_publish_time">Publish Time</translate>
</template>
<template #description>
<translate key="lang_form_edit_publish_time_desc">The time when the episode should be published (according to the stations timezone).</translate>
</template>
<b-form-timepicker id="form_edit_publish_time" v-model="form.publish_time.$model"
:state="form.publish_time.$dirty ? !form.publish_time.$error : null" :locale="locale"></b-form-timepicker>
</b-form-group>
<b-form-group class="col-md-12" label-for="form_edit_explicit">
<template #description>
<translate key="lang_form_edit_explicit_desc">Indicates the presence of explicit content (explicit language or adult content). Apple Podcasts displays an Explicit parental advisory graphic for your episode if turned on. Episodes containing explicit material arent available in some Apple Podcasts territories.</translate>
</template>
<b-form-checkbox id="form_edit_explicit" v-model="form.explicit.$model">
<translate key="lang_form_edit_explicit">Contains explicit content</translate>
</b-form-checkbox>
</b-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
export default {
name: 'EpisodeFormBasicInfo',
props: {
form: Object,
locale: String
},
computed: {
langTitle () {
return this.$gettext('Basic Information');
}
}
};
</script>

View File

@ -0,0 +1,56 @@
<template>
<b-tab :title="langTitle">
<b-form-group>
<b-row>
<b-form-group class="col-md-6" label-for="media_file">
<template #label>
<translate key="media_file">Select Media File</translate>
</template>
<template #description>
<translate key="media_file_desc">Podcast media should be in the MP3 or M4A (AAC) format for the greatest compatibility.</translate>
</template>
<b-form-file id="media_file" accept="audio/x-m4a, audio/mpeg" v-model="form.media_file.$model"></b-form-file>
</b-form-group>
<b-form-group class="col-md-6">
<template #label>
<translate key="existing_media">Current Podcast Media</translate>
</template>
<div v-if="hasMedia">
<p>
{{ media.original_name }}<br>
<small>{{ media.length_text }}</small>
</p>
<div class="buttons">
<b-button :href="downloadUrl" target="_blank" variant="bg">
<translate key="btn_download">Download</translate>
</b-button>
</div>
</div>
<div v-else>
<translate key="no_existing_media">There is no existing media associated with this episode.</translate>
</div>
</b-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
export default {
name: 'EpisodeFormMedia',
props: {
form: Object,
hasMedia: Boolean,
media: Object,
downloadUrl: String
},
computed: {
langTitle () {
return this.$gettext('Media');
}
}
};
</script>

View File

@ -0,0 +1,158 @@
<template>
<div>
<b-card no-body>
<b-card-header header-bg-variant="primary-dark" class="d-flex align-items-center">
<div class="flex-shrink-0 pr-3">
<album-art :src="podcast.art"></album-art>
</div>
<div class="flex-fill">
<h2 class="card-title">{{ podcast.title }}</h2>
<h4 class="card-subtitle text-muted" key="lang_episodes" v-translate>Episodes</h4>
</div>
</b-card-header>
<b-card-body body-class="card-padding-sm">
<b-button variant="bg" @click="doClearPodcast()">
<icon icon="arrow_back"></icon>
<translate key="lang_podcast_back">All Podcasts</translate>
</b-button>
<b-button variant="outline-primary" @click.prevent="doCreate">
<i class="material-icons" aria-hidden="true">add</i>
<translate key="lang_add_episode">Add Episode</translate>
</b-button>
</b-card-body>
<data-table ref="datatable" id="station_podcast_episodes" paginated :fields="fields" :responsive="false"
:api-url="podcast.links.episodes">
<template #cell(art)="row">
<album-art :src="row.item.art"></album-art>
</template>
<template #cell(title)="row">
<h5 class="m-0">{{ row.item.title }}</h5>
<a :href="row.item.links.public" target="_blank">
<translate key="lang_link_public">
Public Page
</translate>
</a>
</template>
<template #cell(podcast_media)="row">
<template v-if="row.item.media">
<span>{{ row.item.media.original_name }}</span>
<br/>
<small>{{ row.item.media.length_text }}</small>
</template>
</template>
<template #cell(explicit)="row">
<span class="badge badge-danger" v-if="row.item.explicit">
<translate key="explicit">Explicit</translate>
</span>
</template>
<template #cell(actions)="row">
<b-button-group size="sm">
<b-button size="sm" variant="primary" @click.prevent="doEdit(row.item.links.self)">
<translate key="lang_btn_edit">Edit</translate>
</b-button>
<b-button size="sm" variant="danger" @click.prevent="doDelete(row.item.links.self)">
<translate key="lang_btn_delete">Delete</translate>
</b-button>
</b-button-group>
</template>
</data-table>
</b-card>
<edit-modal ref="editEpisodeModal" :create-url="podcast.links.episodes" :station-time-zone="stationTimeZone"
:locale="locale" :podcast-id="podcast.id" @relist="relist"></edit-modal>
</div>
</template>
<script>
import DataTable from './../../Common/DataTable';
import EditModal from './EpisodeEditModal';
import axios from 'axios';
import Icon from '../../Common/Icon';
import AlbumArt from '../../Common/AlbumArt';
import EpisodeFormBasicInfo from './EpisodeForm/BasicInfo';
import PodcastCommonArtwork from './Common/Artwork';
export const episodeViewProps = {
props: {
locale: String,
stationTimeZone: String
}
};
export default {
name: 'EpisodesView',
components: { PodcastCommonArtwork, EpisodeFormBasicInfo, AlbumArt, Icon, EditModal, DataTable },
mixins: [episodeViewProps],
props: {
podcast: Object
},
emits: ['clear-podcast'],
data () {
return {
fields: [
{ key: 'art', label: this.$gettext('Art'), sortable: false, class: 'shrink pr-0' },
{ key: 'title', label: this.$gettext('Episode'), sortable: false },
{ key: 'podcast_media', label: this.$gettext('File'), sortable: false },
{ key: 'explicit', label: this.$gettext('Explicit'), sortable: false },
{ key: 'actions', label: this.$gettext('Actions'), sortable: false, class: 'shrink' }
]
};
},
mounted () {
moment.relativeTimeThreshold('ss', 1);
moment.relativeTimeRounding(function (value) {
return Math.round(value * 10) / 10;
});
},
methods: {
formatTime (time) {
return moment(time).tz(this.stationTimeZone).format('LT');
},
formatLength (length) {
return moment.duration(length, 'seconds').humanize();
},
relist () {
if (this.$refs.datatable) {
this.$refs.datatable.refresh();
}
},
doCreate () {
this.$refs.editEpisodeModal.create();
},
doEdit (url) {
this.$refs.editEpisodeModal.edit(url);
},
doClearPodcast () {
this.$emit('clear-podcast');
},
doDelete (url) {
let buttonText = this.$gettext('Delete');
let buttonConfirmText = this.$gettext('Delete episode?');
Swal.fire({
title: buttonConfirmText,
confirmButtonText: buttonText,
confirmButtonColor: '#e64942',
showCancelButton: true,
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();
}).catch((err) => {
console.error(err);
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger');
}
});
}
});
}
}
};
</script>

View File

@ -0,0 +1,154 @@
<template>
<div>
<b-card no-body>
<b-card-header header-bg-variant="primary-dark">
<b-row class="align-items-center">
<b-col md="6">
<h2 class="card-title" key="lang_podcasts" v-translate>Podcasts</h2>
</b-col>
</b-row>
</b-card-header>
<b-card-body body-class="card-padding-sm">
<b-button variant="outline-primary" @click.prevent="doCreate">
<i class="material-icons" aria-hidden="true">add</i>
<translate key="lang_add_podcasts">Add Podcast</translate>
</b-button>
</b-card-body>
<data-table ref="datatable" id="station_podcasts" paginated :fields="fields" :responsive="false"
:api-url="listUrl">
<template #cell(art)="row">
<album-art :src="row.item.art"></album-art>
</template>
<template #cell(title)="row">
<h5 class="m-0">{{ row.item.title }}</h5>
<a :href="row.item.links.public_episodes" target="_blank">
<translate key="lang_link_public_page">
Public Page
</translate>
</a> &bull;
<a :href="row.item.links.public_feed" target="_blank">
<translate key="lang_link_rss_feed">
RSS Feed
</translate>
</a>
</template>
<template #cell(num_episodes)="row">
{{ countEpisodes(row.item.episodes) }}
</template>
<template #cell(actions)="row">
<b-button-group size="sm">
<b-button size="sm" variant="primary" @click.prevent="doEdit(row.item.links.self)">
<translate key="lang_btn_edit">Edit</translate>
</b-button>
<b-button size="sm" variant="danger" @click.prevent="doDelete(row.item.links.self)">
<translate key="lang_btn_delete">Delete</translate>
</b-button>
<b-button size="sm" variant="dark" @click.prevent="doSelectPodcast(row.item)">
<translate key="lang_btn_episodes">Episodes</translate>
</b-button>
</b-button-group>
</template>
</data-table>
</b-card>
<edit-modal ref="editPodcastModal" :create-url="listUrl" :station-time-zone="stationTimeZone"
:language-options="languageOptions" :categories-options="categoriesOptions" @relist="relist"></edit-modal>
</div>
</template>
<script>
import DataTable from '../../Common/DataTable';
import EditModal from './PodcastEditModal';
import axios from 'axios';
import AlbumArt from '../../Common/AlbumArt';
export const listViewProps = {
props: {
listUrl: String,
locale: String,
stationTimeZone: String,
languageOptions: Object,
categoriesOptions: Object
}
};
export default {
name: 'ListView',
components: { AlbumArt, EditModal, DataTable },
mixins: [listViewProps],
emits: ['select-podcast'],
data () {
return {
fields: [
{ key: 'art', label: this.$gettext('Art'), sortable: false, class: 'shrink pr-0' },
{ key: 'title', label: this.$gettext('Podcast'), sortable: false },
{ key: 'num_episodes', label: this.$gettext('# Episodes'), sortable: false },
{ key: 'actions', label: this.$gettext('Actions'), sortable: false, class: 'shrink' }
]
};
},
computed: {
langAllPodcastsTab () {
return this.$gettext('All Podcasts');
}
},
mounted () {
moment.relativeTimeThreshold('ss', 1);
moment.relativeTimeRounding(function (value) {
return Math.round(value * 10) / 10;
});
},
methods: {
formatTime (time) {
return moment(time).tz(this.stationTimeZone).format('LT');
},
formatLength (length) {
return moment.duration(length, 'seconds').humanize();
},
countEpisodes (episodes) {
return episodes.length;
},
relist () {
if (this.$refs.datatable) {
this.$refs.datatable.refresh();
}
},
doCreate () {
this.$refs.editPodcastModal.create();
},
doEdit (url) {
this.$refs.editPodcastModal.edit(url);
},
doSelectPodcast (podcast) {
this.$emit('select-podcast', podcast);
},
doDelete (url) {
let buttonText = this.$gettext('Delete');
let buttonConfirmText = this.$gettext('Delete podcast?');
Swal.fire({
title: buttonConfirmText,
confirmButtonText: buttonText,
confirmButtonColor: '#e64942',
showCancelButton: true,
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();
}).catch((err) => {
console.error(err);
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger');
}
});
}
});
}
}
};
</script>

View File

@ -0,0 +1,220 @@
<template>
<b-modal size="lg" id="edit_modal" ref="modal" :title="langTitle" :busy="loading">
<b-spinner v-if="loading">
</b-spinner>
<b-form class="form" v-else @submit.prevent="doSubmit">
<b-tabs content-class="mt-3">
<podcast-form-basic-info :form="$v.form"
:categories-options="categoriesOptions" :language-options="languageOptions">
</podcast-form-basic-info>
<podcast-common-artwork :form="$v.files" :artwork-src="record.art"></podcast-common-artwork>
</b-tabs>
<invisible-submit-button/>
</b-form>
<template v-slot:modal-footer>
<b-button variant="default" type="button" @click="close">
<translate key="lang_btn_close">Close</translate>
</b-button>
<template v-if="record.has_custom_art">
<b-button variant="danger" type="button" @click="clearArtwork(record.links.art)">
<translate key="lang_btn_clear_artwork">Clear Art</translate>
</b-button>
</template>
<b-button variant="primary" type="submit" @click="doSubmit" :disabled="$v.form.$invalid">
<translate key="lang_btn_save_changes">Save Changes</translate>
</b-button>
</template>
</b-modal>
</template>
<script>
import axios from 'axios';
import { validationMixin } from 'vuelidate';
import required from 'vuelidate/src/validators/required';
import InvisibleSubmitButton from '../../Common/InvisibleSubmitButton';
import PodcastFormBasicInfo from './PodcastForm/BasicInfo';
import PodcastCommonArtwork from './Common/Artwork';
export default {
name: 'EditModal',
components: { PodcastCommonArtwork, PodcastFormBasicInfo, InvisibleSubmitButton },
mixins: [validationMixin],
props: {
createUrl: String,
stationTimeZone: String,
languageOptions: Object,
categoriesOptions: Object
},
data () {
return {
loading: true,
editUrl: null,
record: {
has_custom_art: false,
art: null,
links: {}
},
form: {
'title': '',
'link': '',
'description': '',
'language': 'en',
'categories': []
},
files: {
'artwork_file': null
}
};
},
computed: {
langTitle () {
return this.isEditMode
? this.$gettext('Edit Podcast')
: this.$gettext('Add Podcast');
},
isEditMode () {
return this.editUrl !== null;
}
},
validations: {
form: {
'title': { required },
'link': {},
'description': {},
'language': { required },
'categories': { required }
},
files: {
'artwork_file': {}
}
},
methods: {
resetForm () {
this.record = {
has_custom_art: false,
art: null,
links: {}
};
this.form = {
'title': '',
'link': '',
'description': '',
'language': 'en',
'categories': []
};
this.files = {
'artwork_file': null
};
},
create () {
this.resetForm();
this.loading = false;
this.editUrl = null;
this.$refs.modal.show();
},
edit (recordUrl) {
this.resetForm();
this.loading = true;
this.editUrl = recordUrl;
this.$refs.modal.show();
axios.get(this.editUrl).then((resp) => {
let d = resp.data;
this.record = d;
this.form = {
'title': d.title,
'link': d.link,
'description': d.description,
'language': d.language,
'categories': d.categories
};
this.loading = false;
}).catch((err) => {
console.log(err);
this.close();
});
},
doSubmit () {
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return;
}
let formData = new FormData();
formData.append('body', JSON.stringify(this.form));
Object.entries(this.files).forEach(([key, value]) => {
if (null !== value) {
formData.append(key, value);
}
});
axios({
method: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
}).then((resp) => {
let notifyMessage = this.$gettext('Changes saved.');
notify('<b>' + notifyMessage + '</b>', 'success', false);
this.$emit('relist');
this.close();
}).catch((err) => {
console.error(err);
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
notify('<b>' + notifyMessage + '</b>', 'danger', false);
this.$emit('relist');
this.close();
});
},
clearArtwork (url) {
let buttonText = this.$gettext('Remove Artwork');
let buttonConfirmText = this.$gettext('Delete podcast artwork?');
Swal.fire({
title: buttonConfirmText,
confirmButtonText: buttonText,
confirmButtonColor: '#e64942',
showCancelButton: true,
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.$emit('relist');
this.close();
}).catch((err) => {
console.error(err);
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger');
}
});
}
});
},
close () {
this.loading = false;
this.editUrl = null;
this.clearArtUrl = null;
this.artworkSrc = null;
this.resetForm();
this.$v.form.$reset();
this.$refs.modal.hide();
}
}
};
</script>

View File

@ -0,0 +1,84 @@
<template>
<b-tab :title="langTitle" active>
<b-form-group>
<b-row>
<b-form-group class="col-md-6" label-for="form_edit_title">
<template #label>
<translate key="lang_form_edit_title">Podcast Title</translate>
</template>
<b-form-input id="form_edit_title" type="text" v-model="form.title.$model"
:state="form.title.$dirty ? !form.title.$error : null"></b-form-input>
<b-form-invalid-feedback>
<translate key="lang_error_required">This field is required.</translate>
</b-form-invalid-feedback>
</b-form-group>
<b-form-group class="col-md-6" label-for="form_edit_link">
<template #label>
<translate key="lang_form_edit_link">Website</translate>
</template>
<template #description>
<translate key="lang_form_edit_link_desc">Typically the home page of a podcast.</translate>
</template>
<b-form-input id="form_edit_link" type="text" v-model="form.link.$model"
:state="form.link.$dirty ? !form.link.$error : null"></b-form-input>
</b-form-group>
<b-form-group class="col-md-12" label-for="form_edit_description">
<template #label>
<translate key="lang_form_edit_description">Description</translate>
</template>
<template #description>
<translate key="lang_form_edit_description_desc">The description of your podcast. The typical maximum amount of text allowed for this is 4000 characters.</translate>
</template>
<b-form-textarea id="form_edit_description" v-model="form.description.$model"
:state="form.description.$dirty ? !form.description.$error : null"></b-form-textarea>
</b-form-group>
<b-form-group class="col-md-12" label-for="form_edit_language">
<template #label>
<translate key="lang_form_edit_language">Language</translate>
</template>
<template #description>
<translate key="lang_form_edit_language_desc">The language spoken on the podcast.</translate>
</template>
<b-form-select id="form_edit_language" v-model="form.language.$model" :options="languageOptions"
:state="form.language.$dirty ? !form.language.$error : null"></b-form-select>
<b-form-invalid-feedback>
<translate key="lang_error_required">This field is required.</translate>
</b-form-invalid-feedback>
</b-form-group>
<b-form-group class="col-md-12" label-for="form_edit_categories">
<template #label>
<translate key="lang_form_edit_categories">Categories</translate>
</template>
<template #description>
<translate key="lang_form_edit_categories_desc">Select the category/categories that best reflects the content of your podcast.</translate>
</template>
<b-form-select id="form_edit_categories" v-model="form.categories.$model" :options="categoriesOptions"
:state="form.categories.$dirty ? !form.categories.$error : null" multiple></b-form-select>
<b-form-invalid-feedback>
<translate key="lang_error_required">This field is required.</translate>
</b-form-invalid-feedback>
</b-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
export default {
name: 'PodcastFormBasicInfo',
props: {
form: Object,
languageOptions: Object,
categoriesOptions: Object
},
computed: {
langTitle () {
return this.$gettext('Basic Information');
}
}
};
</script>

View File

@ -31,6 +31,12 @@
<a :href="publicOnDemandUri">{{ publicOnDemandUri }}</a>
</td>
</tr>
<tr>
<td key="lang_profile_podcasts" v-translate>Podcasts</td>
<td>
<a :href="publicPodcastsUri">{{ publicPodcastsUri }}</a>
</td>
</tr>
</tbody>
</table>
<div class="card-actions" v-if="userCanManageProfile">
@ -78,6 +84,7 @@ export const profilePublicProps = {
publicPageUri: String,
publicWebDjUri: String,
publicOnDemandUri: String,
publicPodcastsUri: String,
togglePublicPageUri: String
}
};

View File

@ -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',

View File

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

View File

@ -0,0 +1,386 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations;
use App\Acl;
use App\Controller\Api\AbstractApiCrudController;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class PodcastEpisodesController extends AbstractApiCrudController
{
protected string $entityClass = Entity\PodcastEpisode::class;
protected string $resourceRouteName = 'api:stations:podcast:episode';
public function __construct(
EntityManagerInterface $em,
Serializer $serializer,
ValidatorInterface $validator,
protected Entity\Repository\StationRepository $stationRepository,
protected Entity\Repository\PodcastRepository $podcastRepository,
protected Entity\Repository\PodcastMediaRepository $podcastMediaRepository,
protected Entity\Repository\PodcastEpisodeRepository $episodeRepository
) {
parent::__construct($em, $serializer, $validator);
}
/**
* @OA\Get(path="/station/{station_id}/podcast/{podcast_id}/episodes",
* tags={"Stations: Podcasts"},
* description="List all current episodes for a given podcast ID.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="podcast_id",
* in="path",
* description="Podcast ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(type="array", @OA\Items(ref="#/components/schemas/Api_PodcastEpisode"))
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Post(path="/station/{station_id}/podcast/{podcast_id}/episodes",
* tags={"Stations: Podcasts"},
* description="Create a new podcast episode.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="podcast_id",
* in="path",
* description="Podcast ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\RequestBody(
* @OA\JsonContent(ref="#/components/schemas/Api_PodcastEpisode")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_PodcastEpisode")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Get(path="/station/{station_id}/podcast/{podcast_id}/episode/{id}",
* tags={"Stations: Podcasts"},
* description="Retrieve details for a single podcast episode.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="podcast_id",
* in="path",
* description="Podcast ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Parameter(
* name="id",
* in="path",
* description="Podcast Episode ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_PodcastEpisode")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Put(path="/station/{station_id}/podcast/{podcast_id}/episode/{id}",
* tags={"Stations: Podcasts"},
* description="Update details of a single podcast episode.",
* @OA\RequestBody(
* @OA\JsonContent(ref="#/components/schemas/Api_PodcastEpisode")
* ),
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="podcast_id",
* in="path",
* description="Podcast ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Parameter(
* name="id",
* in="path",
* description="Podcast Episode ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_Status")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Delete(path="/station/{station_id}/podcast/{podcast_id}/episode/{id}",
* tags={"Stations: Podcasts"},
* description="Delete a single podcast episode.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="podcast_id",
* in="path",
* description="Podcast ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Parameter(
* name="id",
* in="path",
* description="Podcast Episode ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_Status")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*/
/**
* @inheritDoc
*/
public function listAction(
ServerRequest $request,
Response $response,
string $podcast_id
): ResponseInterface {
$station = $request->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();
}
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Art;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
class DeleteArtAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\PodcastRepository $podcastRepo,
EntityManagerInterface $em,
string $podcast_id,
): ResponseInterface {
$station = $request->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.')
)
);
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Art;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class GetArtAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\StationRepository $stationRepo,
Entity\Repository\PodcastRepository $podcastRepo,
string $podcast_id,
): ResponseInterface {
$station = $request->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
);
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Episodes\Art;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
class DeleteArtAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\PodcastEpisodeRepository $episodeRepo,
EntityManagerInterface $em,
string $episode_id
): ResponseInterface {
$station = $request->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.')));
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Episodes\Art;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class GetArtAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\StationRepository $stationRepo,
string $podcast_id,
string $episode_id,
): ResponseInterface {
$station = $request->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
);
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Episodes;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class DownloadAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\PodcastEpisodeRepository $episodeRepo,
string $episode_id
): ResponseInterface {
set_time_limit(600);
$station = $request->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.'));
}
}

View File

@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations;
use App\Acl;
use App\Controller\Api\AbstractApiCrudController;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Annotations as OA;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class PodcastsController extends AbstractApiCrudController
{
protected string $entityClass = Entity\Podcast::class;
protected string $resourceRouteName = 'api:stations:podcast';
public function __construct(
EntityManagerInterface $em,
Serializer $serializer,
ValidatorInterface $validator,
protected Entity\Repository\StationRepository $stationRepository,
protected Entity\Repository\PodcastRepository $podcastRepository
) {
parent::__construct($em, $serializer, $validator);
}
/**
* @OA\Get(path="/station/{station_id}/podcasts",
* tags={"Stations: Podcasts"},
* description="List all current podcasts.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(type="array", @OA\Items(ref="#/components/schemas/Api_Podcast"))
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Post(path="/station/{station_id}/podcasts",
* tags={"Stations: Podcasts"},
* description="Create a new podcast.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\RequestBody(
* @OA\JsonContent(ref="#/components/schemas/Api_Podcast")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_Podcast")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Get(path="/station/{station_id}/podcast/{id}",
* tags={"Stations: Podcasts"},
* description="Retrieve details for a single podcast.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="id",
* in="path",
* description="Podcast ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_Podcast")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Put(path="/station/{station_id}/podcast/{id}",
* tags={"Stations: Podcasts"},
* description="Update details of a single podcast.",
* @OA\RequestBody(
* @OA\JsonContent(ref="#/components/schemas/Api_Podcast")
* ),
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="id",
* in="path",
* description="Podcast ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_Status")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Delete(path="/station/{station_id}/podcast/{id}",
* tags={"Stations: Podcasts"},
* description="Delete a single podcast.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="id",
* in="path",
* description="Podcast ID",
* required=true,
* @OA\Schema(type="string")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_Status")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*/
/**
* @inheritDoc
*/
public function listAction(ServerRequest $request, Response $response): ResponseInterface
{
$station = $request->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();
}
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend\PublicPages;
use App\Entity\Repository\PodcastEpisodeRepository;
use App\Entity\Repository\PodcastRepository;
use App\Exception\PodcastNotFoundException;
use App\Exception\StationNotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Session\Flash;
use Psr\Http\Message\ResponseInterface;
class PodcastEpisodeController
{
public function __construct(
protected PodcastRepository $podcastRepository,
protected PodcastEpisodeRepository $episodeRepository
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $podcast_id,
string $episode_id
): ResponseInterface {
$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();
}
$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,
]
);
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend\PublicPages;
use App\Entity\Repository\PodcastEpisodeRepository;
use App\Entity\Repository\PodcastRepository;
use App\Exception\PodcastNotFoundException;
use App\Exception\StationNotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Session\Flash;
use Psr\Http\Message\ResponseInterface;
class PodcastEpisodesController
{
public function __construct(
protected PodcastRepository $podcastRepository,
protected PodcastEpisodeRepository $episodeRepository
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $podcast_id
): ResponseInterface {
$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();
}
$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,
]
);
}
}

View File

@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend\PublicPages;
use App\Entity\Podcast;
use App\Entity\PodcastCategory;
use App\Entity\PodcastEpisode;
use App\Entity\Repository\PodcastRepository;
use App\Entity\Repository\StationRepository;
use App\Entity\Station;
use App\Exception\PodcastNotFoundException;
use App\Exception\StationNotFoundException;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\RouterInterface;
use App\Http\ServerRequest;
use GuzzleHttp\Psr7\UriResolver;
use MarcW\RssWriter\Extension\Atom\AtomLink;
use MarcW\RssWriter\Extension\Atom\AtomWriter;
use MarcW\RssWriter\Extension\Core\Category as RssCategory;
use MarcW\RssWriter\Extension\Core\Channel as RssChannel;
use MarcW\RssWriter\Extension\Core\CoreWriter;
use MarcW\RssWriter\Extension\Core\Enclosure as RssEnclosure;
use MarcW\RssWriter\Extension\Core\Guid as RssGuid;
use MarcW\RssWriter\Extension\Core\Image as RssImage;
use MarcW\RssWriter\Extension\Core\Item as RssItem;
use MarcW\RssWriter\Extension\DublinCore\DublinCore;
use MarcW\RssWriter\Extension\DublinCore\DublinCoreWriter;
use MarcW\RssWriter\Extension\Itunes\ItunesChannel;
use MarcW\RssWriter\Extension\Itunes\ItunesItem;
use MarcW\RssWriter\Extension\Itunes\ItunesWriter;
use MarcW\RssWriter\Extension\Slash\Slash;
use MarcW\RssWriter\Extension\Slash\SlashWriter;
use MarcW\RssWriter\Extension\Sy\Sy;
use MarcW\RssWriter\Extension\Sy\SyWriter;
use MarcW\RssWriter\RssWriter;
use Psr\Http\Message\ResponseInterface;
class PodcastFeedController
{
protected RouterInterface $router;
public function __construct(
protected StationRepository $stationRepository,
protected PodcastRepository $podcastRepository
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $podcast_id,
): ResponseInterface {
$this->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;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend\PublicPages;
use App\Entity\Repository\PodcastRepository;
use App\Exception\StationNotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class PodcastsController
{
public function __construct(
protected PodcastRepository $podcastRepository
) {
}
public function __invoke(ServerRequest $request, Response $response): ResponseInterface
{
$station = $request->getStation();
if (!$station->getEnablePublicPage()) {
throw new StationNotFoundException();
}
$publishedPodcasts = $this->podcastRepository->fetchPublishedPodcastsForStation($station);
return $request->getView()->renderToResponse($response, 'frontend/public/podcasts', [
'podcasts' => $publishedPodcasts,
'station' => $station,
]);
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Controller\Stations;
use App\Entity\PodcastCategory;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Intl\Languages;
class PodcastsAction
{
public function __invoke(ServerRequest $request, Response $response): ResponseInterface
{
$station = $request->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,
]
);
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Entity\Api;
use App\Entity\Api\Traits\HasLinks;
use OpenApi\Annotations as OA;
/**
* @OA\Schema(type="object", schema="Api_Podcast")
*/
class Podcast
{
use HasLinks;
/**
* @OA\Property()
*/
public ?string $id = null;
/**
* @OA\Property()
*/
public ?int $storage_location_id = null;
/**
* @OA\Property()
*/
public ?string $title = null;
/**
* @OA\Property()
*/
public ?string $link = null;
/**
* @OA\Property()
*/
public ?string $description = null;
/**
* @OA\Property()
*/
public ?string $language = null;
/**
* @OA\Property()
*/
public bool $has_custom_art = false;
/**
* @OA\Property()
*/
public ?string $art = null;
/**
* @OA\Property()
*/
public int $art_updated_at = 0;
/**
* @OA\Property(@OA\Items(type="string"))
*/
public array $categories = [];
/**
* @OA\Property(@OA\Items(type="string"))
*/
public array $episodes = [];
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Entity\Api;
use App\Entity\Api\Traits\HasLinks;
use OpenApi\Annotations as OA;
/**
* @OA\Schema(type="object", schema="Api_PodcastEpisode")
*/
class PodcastEpisode
{
use HasLinks;
/**
* @OA\Property()
*/
public ?string $id = null;
/**
* @OA\Property()
*/
public ?string $title = null;
/**
* @OA\Property()
*/
public ?string $description = null;
/**
* @OA\Property()
*/
public bool $explicit = false;
/**
* @OA\Property()
*/
public ?int $publish_at = null;
/**
* @OA\Property()
*/
public bool $has_media = false;
/**
* @OA\Property()
*/
public PodcastMedia $media;
/**
* @OA\Property()
*/
public bool $has_custom_art = false;
/**
* @OA\Property()
*/
public ?string $art = null;
/**
* @OA\Property()
*/
public int $art_updated_at = 0;
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Entity\Api;
use OpenApi\Annotations as OA;
/**
* @OA\Schema(type="object", schema="Api_PodcastMedia")
*/
class PodcastMedia
{
/**
* @OA\Property()
*/
public ?string $id = null;
/**
* @OA\Property()
*/
public ?string $original_name = null;
/**
* @OA\Property()
*/
public float $length = 0.0;
/**
* @OA\Property()
*/
public ?string $length_text = null;
/**
* @OA\Property()
*/
public ?string $path = null;
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Entity\Fixture;
use App\Entity;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class Podcast extends AbstractFixture implements DependentFixtureInterface
{
public function load(ObjectManager $em): void
{
/** @var Entity\Station $station */
$station = $this->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,
];
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Entity\Fixture;
use App\Entity;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\Finder\Finder;
class PodcastEpisode extends AbstractFixture implements DependentFixtureInterface
{
protected Entity\Repository\PodcastMediaRepository $mediaRepo;
public function __construct(Entity\Repository\PodcastMediaRepository $mediaRepo)
{
$this->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,
];
}
}

View File

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

View File

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

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20210512225946 extends AbstractMigration
{
public function getDescription(): string
{
return 'Initial Podcast table setup.';
}
public function up(Schema $schema): void
{
$this->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');
}
}

198
src/Entity/Podcast.php Normal file
View File

@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Annotations\AuditLog;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="podcast")
* @ORM\Entity
*
* @AuditLog\Auditable
*/
class Podcast
{
use Traits\TruncateStrings;
public const DIR_PODCAST_ARTWORK = '.podcast_art';
/**
* @ORM\Id
* @ORM\Column(name="id", type="guid", unique=true)
* @ORM\GeneratedValue(strategy="UUID")
*
* @var string|null
*/
protected $id;
/**
* @ORM\ManyToOne(targetEntity="StorageLocation")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="storage_location_id", referencedColumnName="id", onDelete="CASCADE")
* })
*
* @var StorageLocation
*/
protected $storage_location;
/**
* @ORM\Column(name="title", type="string", length=255)
*
* @Assert\NotBlank
*
* @var string The name of your podcast
*/
protected $title;
/**
* @ORM\Column(name="link", type="string", length=255, nullable=true)
*
* @var string|null A link to your website
*/
protected $link;
/**
* @ORM\Column(name="description", type="text")
*
* @Assert\NotBlank
*
* @var string A description of your podcast
*/
protected $description;
/**
* @ORM\Column(name="language", type="string", length=2)
*
* @Assert\NotBlank
*
* @var string The ISO 639-1 language code for your podcast
*/
protected $language;
/**
* @ORM\Column(name="art_updated_at", type="integer")
* @AuditLog\AuditIgnore()
*
* @var int The latest time (UNIX timestamp) when album art was updated.
*/
protected $art_updated_at = 0;
/**
* @ORM\OneToMany(targetEntity="PodcastCategory", mappedBy="podcast")
*
* @var Collection
*/
protected $categories;
/**
* @ORM\OneToMany(targetEntity="PodcastEpisode", mappedBy="podcast")
*
* @var Collection
*/
protected $episodes;
public function __construct(StorageLocation $storageLocation)
{
$this->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';
}
}

View File

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Annotations\AuditLog;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="podcast_category")
* @ORM\Entity
*
* @AuditLog\Auditable
*/
class PodcastCategory
{
use Traits\TruncateStrings;
public const CATEGORY_SEPARATOR = '|';
/**
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*
* @var int|null
*/
protected $id;
/**
* @ORM\ManyToOne(targetEntity="Podcast", inversedBy="categories")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="podcast_id", referencedColumnName="id", onDelete="CASCADE")
* })
*
* @var Podcast
*/
protected $podcast;
/**
* @ORM\Column(name="category", type="string", length=255)
*
* @Assert\NotBlank
*
* @var string The combined category and sub-category (if provided).
*/
protected $category;
public function __construct(Podcast $podcast, string $category)
{
$this->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;
}
}

View File

@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Annotations\AuditLog;
use App\Entity\Traits;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="podcast_episode")
* @ORM\Entity
*
* @AuditLog\Auditable
*/
class PodcastEpisode
{
use Traits\TruncateStrings;
public const DIR_PODCAST_EPISODE_ARTWORK = '.podcast_episode_art';
/**
* @ORM\Id
* @ORM\Column(name="id", type="guid", unique=true)
* @ORM\GeneratedValue(strategy="UUID")
*
* @var string|null
*/
protected $id;
/**
* @ORM\ManyToOne(targetEntity="Podcast", inversedBy="episodes")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="podcast_id", referencedColumnName="id", onDelete="CASCADE")
* })
*
* @var Podcast
*/
protected $podcast;
/**
* @ORM\OneToOne(targetEntity="PodcastMedia", mappedBy="episode")
*
* @var PodcastMedia|null
*/
protected $media;
/**
* @ORM\Column(name="title", type="string", length=255)
*
* @Assert\NotBlank
*
* @var string The name of the episode
*/
protected $title;
/**
* @ORM\Column(name="link", type="string", length=255, nullable=true)
*
* @var string|null A link to the episodes website
*/
protected $link;
/**
* @ORM\Column(name="description", type="text")
*
* @Assert\NotBlank
*
* @var string A description of the episode
*/
protected $description;
/**
* @ORM\Column(name="publish_at", type="integer", nullable=true)
*
* @var int|null Timestamp of when the episode should be published
*/
protected $publish_at;
/**
* @ORM\Column(name="explicit", type="boolean")
*
* @var bool Whether the episode contains explicit content or not
*/
protected $explicit;
/**
* @ORM\Column(name="created_at", type="integer")
*
* @var int Timestamp of when the episode was created
*/
protected $created_at;
/**
* @ORM\Column(name="art_updated_at", type="integer")
* @AuditLog\AuditIgnore()
*
* @var int The latest time (UNIX timestamp) when album art was updated.
*/
protected $art_updated_at = 0;
public function __construct(Podcast $podcast)
{
$this->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;
}
}

242
src/Entity/PodcastMedia.php Normal file
View File

@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Annotations\AuditLog;
use App\Entity\Traits;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="podcast_media")
* @ORM\Entity()
*
* @AuditLog\Auditable
*/
class PodcastMedia
{
use Traits\TruncateStrings;
/**
* @ORM\Id
* @ORM\Column(name="id", type="guid", unique=true)
* @ORM\GeneratedValue(strategy="UUID")
*
* @var string|null
*/
protected $id;
/**
* @ORM\ManyToOne(targetEntity="StorageLocation")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="storage_location_id", referencedColumnName="id", onDelete="CASCADE")
* })
*
* @var StorageLocation
*/
protected $storage_location;
/**
* @ORM\OneToOne(targetEntity="PodcastEpisode", inversedBy="media")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="episode_id", referencedColumnName="id", onDelete="SET NULL")
* })
*
* @var PodcastEpisode|null
*/
protected $episode;
/**
* @ORM\Column(name="original_name", type="string", length=200)
*
* @Assert\NotBlank
*
* @var string The original name of the podcast media file.
*/
protected $original_name;
/**
* @ORM\Column(name="length", type="decimal", precision=7, scale=2)
*
* @var float The podcast media's duration in seconds.
*/
protected $length = 0.00;
/**
* @ORM\Column(name="length_text", type="string", length=10)
*
* @var string The formatted podcast media's duration (in mm:ss format)
*/
protected $length_text = '0:00';
/**
* @ORM\Column(name="path", type="string", length=500)
*
* @Assert\NotBlank
*
* @var string The relative path of the podcast media file.
*/
protected $path;
/**
* @ORM\Column(name="mime_type", type="string", length=255)
*
* @Assert\NotBlank
*
* @var string The mime type of the podcast media file.
*/
protected $mime_type = 'application/octet-stream';
/**
* @ORM\Column(name="modified_time", type="integer")
*
* @var int Timestamp of when the podcast media was last modified
*/
protected $modified_time = 0;
/**
* @ORM\Column(name="art_updated_at", type="integer")
* @AuditLog\AuditIgnore()
*
* @var int The latest time (UNIX timestamp) when album art was updated.
*/
protected $art_updated_at = 0;
public function __construct(StorageLocation $storageLocation)
{
$this->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;
}
}

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Entity\Repository;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Doctrine\Repository;
use App\Entity\Podcast;
use App\Entity\PodcastEpisode;
use App\Entity\Station;
use App\Entity\StorageLocation;
use App\Environment;
use Azura\Files\ExtendedFilesystemInterface;
use Intervention\Image\Constraint;
use Intervention\Image\ImageManager;
use League\Flysystem\UnableToDeleteFile;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\Serializer;
class PodcastEpisodeRepository extends Repository
{
public function __construct(
ReloadableEntityManagerInterface $entityManager,
Serializer $serializer,
Environment $environment,
LoggerInterface $logger,
protected ImageManager $imageManager,
protected PodcastMediaRepository $podcastMediaRepo,
) {
parent::__construct($entityManager, $serializer, $environment, $logger);
}
public function fetchEpisodeForStation(Station $station, string $episodeId): ?PodcastEpisode
{
return $this->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();
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Entity\Repository;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Doctrine\Repository;
use App\Entity\PodcastEpisode;
use App\Entity\PodcastMedia;
use App\Environment;
use App\Exception\InvalidPodcastMediaFileException;
use App\Media\MetadataService\GetId3MetadataService;
use Azura\Files\ExtendedFilesystemInterface;
use Intervention\Image\ImageManager;
use League\Flysystem\UnableToDeleteFile;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\Serializer;
class PodcastMediaRepository extends Repository
{
public function __construct(
ReloadableEntityManagerInterface $em,
Serializer $serializer,
Environment $environment,
LoggerInterface $logger,
protected GetId3MetadataService $metadataService,
protected ImageManager $imageManager
) {
parent::__construct($em, $serializer, $environment, $logger);
}
public function upload(
PodcastEpisode $episode,
string $originalPath,
string $uploadPath,
?ExtendedFilesystemInterface $fs = null
): ?string {
$podcast = $episode->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();
}
}

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Entity\Repository;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Doctrine\Repository;
use App\Entity\Podcast;
use App\Entity\Station;
use App\Entity\StorageLocation;
use App\Environment;
use Azura\Files\ExtendedFilesystemInterface;
use Intervention\Image\Constraint;
use Intervention\Image\ImageManager;
use League\Flysystem\UnableToDeleteFile;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\Serializer;
class PodcastRepository extends Repository
{
public function __construct(
ReloadableEntityManagerInterface $entityManager,
Serializer $serializer,
Environment $environment,
LoggerInterface $logger,
protected ImageManager $imageManager,
protected PodcastEpisodeRepository $podcastEpisodeRepo,
) {
parent::__construct($entityManager, $serializer, $environment, $logger);
}
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,
]
);
}
/**
* @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();
}
}

View File

@ -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 [];

View File

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

View File

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

View File

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

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use App\Exception;
use Psr\Log\LogLevel;
use Throwable;
class InvalidPodcastMediaFileException extends Exception
{
public function __construct(
string $message = 'Invalid Podcast Media mime type.',
int $code = 0,
Throwable $previous = null,
string $loggerLevel = LogLevel::INFO
) {
parent::__construct($message, $code, $previous, $loggerLevel);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use App\Exception;
use Psr\Log\LogLevel;
use Throwable;
class PodcastEpisodeNotFoundException extends Exception
{
public function __construct(
string $message = 'Episode not found.',
int $code = 0,
Throwable $previous = null,
string $loggerLevel = LogLevel::INFO
) {
parent::__construct($message, $code, $previous, $loggerLevel);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use App\Exception;
use Psr\Log\LogLevel;
use Throwable;
class PodcastMediaNotFoundException extends Exception
{
public function __construct(
string $message = 'Podcast media not found.',
int $code = 0,
Throwable $previous = null,
string $loggerLevel = LogLevel::INFO
) {
parent::__construct($message, $code, $previous, $loggerLevel);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use App\Exception;
use Psr\Log\LogLevel;
use Throwable;
class PodcastMediaProcessingException extends Exception
{
public function __construct(
string $message = 'The podcast media provided could not be processed.',
int $code = 0,
Throwable $previous = null,
string $loggerLevel = LogLevel::ERROR
) {
parent::__construct($message, $code, $previous, $loggerLevel);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use App\Exception;
use Psr\Log\LogLevel;
use Throwable;
class PodcastNotFoundException extends Exception
{
public function __construct(
string $message = 'Podcast not found.',
int $code = 0,
Throwable $previous = null,
string $loggerLevel = LogLevel::INFO
) {
parent::__construct($message, $code, $previous, $loggerLevel);
}
}

View File

@ -15,6 +15,8 @@ class StationFilesystems
protected ExtendedFilesystemInterface $fsRecordings;
protected ExtendedFilesystemInterface $fsPodcasts;
protected LocalFilesystem $fsPlaylists;
protected LocalFilesystem $fsConfig;
@ -56,6 +58,21 @@ class StationFilesystems
return $this->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)) {

View File

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

View File

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

View File

@ -95,6 +95,8 @@ class GetId3MetadataService
$metadata->setArtwork($info['id3v2']['PIC'][0]['data']);
}
$metadata->setMimeType($info['mime_type']);
return $metadata;
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Message;
use App\MessageQueue\QueueManager;
class AddNewPodcastMediaMessage extends AbstractUniqueMessage
{
/** @var int The numeric identifier for the StorageLocation entity. */
public int $storageLocationId;
/** @var string The relative path for the podcast media file to be processed. */
public string $path;
public function getQueue(): string
{
return QueueManager::QUEUE_PODCAST_MEDIA;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Message;
use App\MessageQueue\QueueManager;
class ReprocessPodcastMediaMessage extends AbstractUniqueMessage
{
/** @var int The numeric identifier for the PodcastMedia record being processed. */
public int $podcastMediaId;
/** @var bool Whether to force reprocessing even if checks indicate it is not necessary. */
public bool $force = false;
public function getIdentifier(): string
{
return 'ReprocessPodcastMediaMessage_' . $this->podcastMediaId;
}
public function getQueue(): string
{
return QueueManager::QUEUE_PODCAST_MEDIA;
}
}

View File

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

View File

@ -0,0 +1,45 @@
<?php
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* When sending data using multipart forms (that also include uploaded files, for example),
* it isn't possible to encode JSON values (i.e. booleans) in the other submitted values.
*
* This allows an alternative body format, where the entirety of the JSON-parseable body is
* set in any multipart parameter, parsed, and then assigned to the "parsedBody"
* attribute of the PSR-7 request. This implementation is transparent to any controllers
* using this code.
*/
class HandleMultipartJson implements MiddlewareInterface
{
/**
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$parsedBody = array_filter(
$request->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);
}
}

View File

@ -0,0 +1,105 @@
<?php
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\Exception\PodcastNotFoundException;
use App\Http\Response;
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
*/
class RequirePublishedPodcastEpisodeMiddleware
{
public function __construct(
protected PodcastRepository $podcastRepository
) {
}
public function __invoke(ServerRequest $request, RequestHandlerInterface $handler): ResponseInterface
{
$user = $this->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;
}
}

View File

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

View File

@ -0,0 +1,3 @@
$(function () {
$('[data-toggle="tooltip"]').tooltip();
});

View File

@ -0,0 +1,119 @@
<?php
use Carbon\CarbonImmutable;
$this->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');
?>
<link rel="alternate" type="application/rss+xml" title="<?=$this->e($podcast->getTitle())?>" href="<?=$feedLink?>">
<?php
$this->end();
?>
<section id="content" role="main" class="d-flex align-items-stretch" style="height: 100vh;">
<div class="container pt-5 pb-5 h-100" style="flex: 1;">
<div id="station_podcast_episode">
<div class="row mb-4">
<h1 class="mx-auto"><?=$this->e($podcast->getTitle())?></h1>
</div>
<div class="row justify-content-center mb-4">
<a href="<?=$podcastEpisodesLink?>" class="btn btn-primary mr-2"><span class="material-icons">chevron_left</span><?=__(
'Back'
)?></a>
<a href="<?=$feedLink?>" class="btn btn-warning" target="_blank"><span class="material-icons">rss_feed</span> <?=__(
'RSS Feed'
)?></a>
</div>
<div class="row justify-content-center mb-4">
<div class="col-12 col-lg-10">
<div class="card">
<div class="row no-gutters">
<div class="col-3 col-lg-2">
<img src="<?=$router->named(
'api:stations:podcast:episode:art',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
'episode_id' => $episode->getId() . '|' . $episode->getArtUpdatedAt(),
]
);?>" class="card-img img-fluid" alt="<?=$this->e($podcast->getTitle())?>">
</div>
<div class="col-9 col-lg-10">
<div class="card-body d-flex flex-column h-100">
<div class="row justify-content-between">
<div class="col-6">
<span class="badge badge-pill badge-dark" data-toggle="tooltip" data-placement="right" data-html="true" title="<span class='material-icons'>schedule</span> <?=$publishedAt->format(
'H:i'
)?>"><?=$publishedAt->format('d. M. Y')?></span>
</div>
<?php
if ($episode->getExplicit()) : ?>
<div class="col-6 text-right">
<span class="badge badge-pill badge-danger"><?=__('Explicit') ?></span>
</div>
<?php endif; ?>
</div>
<div class="row mt-3 mb-3">
<div class="col">
<h2 class="card-title text-center"><?=$this->e($episode->getTitle()) ?></h2>
</div>
</div>
<audio src="<?=$episodeAudioSrc ?>" controls class="mt-auto" style="width: 100%;"></audio>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col-12 col-lg-10">
<div class="card">
<div class="card-header bg-primary">
<h5 class="card-title text-center"><?=__('Description') ?></h5>
</div>
<div class="card-body">
<p class="card-text"><?=$this->e($episode->getDescription()) ?></p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,3 @@
$(function () {
$('[data-toggle="tooltip"]').tooltip();
});

View File

@ -0,0 +1,109 @@
<?php
use Carbon\CarbonImmutable;
$this->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');
?>
<link rel="alternate" type="application/rss+xml" title="<?=$this->e($podcast->getTitle())?>" href="<?=$feedLink?>">
<?php
$this->end();
?>
<section id="content" role="main" class="d-flex align-items-stretch" style="height: 100vh;">
<div class="container pt-5 pb-5 h-100" style="flex: 1;">
<div id="station_podcast_episodes">
<div class="row">
<h1 class="mx-auto"><?=__('Episodes')?></h1>
</div>
<div class="row mb-4">
<h2 class="mx-auto"><?=$this->e($podcast->getTitle())?></h2>
</div>
<div class="row justify-content-center mb-4">
<a href="<?=$podcastsLink?>" class="btn btn-primary mr-2"><span class="material-icons">chevron_left</span><?=__(
'Back'
)?></a>
<a href="<?=$feedLink?>" class="btn btn-warning" target="_blank"><span class="material-icons">rss_feed</span> <?=__(
'RSS Feed'
)?></a>
</div>
<div class="row justify-content-center">
<?php
/** @var App\Entity\PodcastEpisode $episode */ ?>
<?php
foreach ($episodes as $episode) : ?>
<?php
$episodePageLink = $router->named('public:podcast:episode',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
'episode_id' => $episode->getId(),
]
) ?>
<div class="col-12 col-lg-8 mb-4">
<div class="card">
<div class="row no-gutters">
<div class="col-md-4">
<a href="<?=$this->e($episodePageLink)?>" title="<?=__('View Details')?>">
<img src="<?=$router->named('api:stations:podcast:episode:art',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
'episode_id' => $episode->getId(
) . '|' . $episode->getArtUpdatedAt(),
]
);?>" class="card-img img-fluid" alt="<?=$this->e($podcast->getTitle())?>">
</a>
</div>
<div class="col-md-8">
<div class="card-body d-flex flex-column h-100">
<h5 class="card-title"><?=$this->e($episode->getTitle())?></h5>
<p class="card-text"><?=$this->e($episode->getDescription())?></p>
<?php
if ($episode->getExplicit()) : ?>
<p class="card-text">
<i class="material-icons" aria-hidden="true">warning</i>
<small class="text-muted"><?=__('Contains explicit content') ?></small>
</p>
<?php endif; ?>
<p class="card-text">
<?php $publishedAt = CarbonImmutable::createFromTimestamp($episode->getCreatedAt()); ?>
<?php if ($episode->getPublishAt() !== null) : ?>
<?php $publishedAt = CarbonImmutable::createFromTimestamp($episode->getPublishAt()); ?>
<?php endif; ?>
<span class="badge badge-pill badge-dark" data-toggle="tooltip" data-placement="right" data-html="true" title="<span class='material-icons'>schedule</span> <?=$publishedAt->format('H:i') ?>"><?=$publishedAt->format('d. M. Y') ?></span>
</p>
<a href="<?=$this->e($episodePageLink) ?>" class="btn btn-primary btn-block mt-auto"><?=__('View Details') ?></a>
</div>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,106 @@
<?php
$this->layout('minimal', [
'page_class' => 'podcasts station-' . $station->getShortName(),
'title' => 'Podcasts - ' . $this->e($station->getName()),
'hide_footer' => true,
]);
?>
<section id="content" role="main" class="d-flex align-items-stretch" style="height: 100vh;">
<div class="container pt-5 pb-5 h-100" style="flex: 1;">
<div id="station_podcasts">
<div class="row mb-4">
<h1 class="mx-auto"><?=$this->e($station->getName())?></h1>
</div>
<div class="row justify-content-center">
<?php
/** @var App\Entity\Podcast $podcast */ ?>
<?php
foreach ($podcasts as $podcast) : ?>
<?php
$episodesPageLink = $router->named(
'public:podcast:episodes',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
]
) ?>
<?php
$feedLink = $router->named(
'public:podcast:feed',
['station_id' => $station->getId(), 'podcast_id' => $podcast->getId()]
) ?>
<div class="col col-md-10 mb-4">
<div class="card">
<div class="row no-gutters">
<div class="col-md-4">
<a href="<?=$this->e($episodesPageLink)?>" title="<?=__('Episodes')?>">
<img src="<?=$router->named(
'api:stations:podcast:art',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
]
);?>" class="card-img img-fluid" alt="<?=$this->e($podcast->getTitle())?>">
</a>
</div>
<div class="col-md-8">
<div class="card-body d-flex flex-column h-100">
<h5 class="card-title"><?=$this->e($podcast->getTitle())?></h5>
<p class="card-text"><?=$this->e($podcast->getDescription())?></p>
<p class="card-text">
<small class="text-muted"><?=__('Language')?>: <?=strtoupper(
$podcast->getLanguage()
)?></small>
<br/>
<small class="text-muted"><?=__('Categories')?>: <?=implode(
$podcast->getCategories()->map(
function ($category) {
$title = $category->getTitle();
$subtitle = $category->getSubTitle();
return (!empty($subtitle))
? $title . ' - ' . $subtitle
: $title;
}
)->getValues()
);?></small>
</p>
<div class="row mb-3">
<div class="col">
<a href="<?=$feedLink?>" class="btn btn-warning btn-sm" target="_blank"><span class="material-icons">rss_feed</span> <?=__(
'RSS Feed'
)?></a>
</div>
</div>
<div class="mt-auto">
<a href="<?=$episodesPageLink?>" class="btn btn-primary btn-block"><?=__(
'Episodes'
)?></a>
</div>
</div>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (count($podcasts) === 0) : ?>
<div class="col col-md-10 mb-4">
<div class="card">
<div class="card-body p-4">
<h5 class="card-title text-center"><?=__('No entries found.') ?></h5>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,20 @@
<?php
$this->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);
?>
<div id="station-podcasts"></div>

View File

@ -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'],

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 897 KiB