Merge commit 'fa73cd82dcbe56db5e233169a41493f658a7fe1b'
This commit is contained in:
commit
5726a5c90f
|
@ -114,6 +114,7 @@ return static function (CallableEventDispatcherInterface $dispatcher) {
|
|||
$e->addTasks([
|
||||
App\Sync\Task\CheckFolderPlaylistsTask::class,
|
||||
App\Sync\Task\CheckMediaTask::class,
|
||||
App\Sync\Task\CheckPodcastPlaylistsTask::class,
|
||||
App\Sync\Task\CheckRequestsTask::class,
|
||||
App\Sync\Task\CheckUpdatesTask::class,
|
||||
App\Sync\Task\CleanupHistoryTask::class,
|
||||
|
|
|
@ -125,6 +125,9 @@ return static function (RouteCollectorProxy $group) {
|
|||
$group->post('/podcasts/art', Controller\Api\Stations\Podcasts\Art\PostArtAction::class)
|
||||
->setName('api:stations:podcasts:new-art');
|
||||
|
||||
$group->get('/podcasts/playlists', Controller\Api\Stations\Podcasts\PlaylistsAction::class)
|
||||
->setName('api:stations:podcasts:playlists');
|
||||
|
||||
$group->group(
|
||||
'/podcast/{podcast_id}',
|
||||
function (RouteCollectorProxy $group) {
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
<template>
|
||||
<section
|
||||
class="card"
|
||||
role="region"
|
||||
>
|
||||
<div class="card-header text-bg-primary">
|
||||
<card-page>
|
||||
<template #header>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-7">
|
||||
<div class="d-flex align-items-center">
|
||||
|
@ -27,9 +24,18 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body buttons">
|
||||
</template>
|
||||
<template
|
||||
v-if="!podcastIsManual"
|
||||
#info
|
||||
>
|
||||
<p class="card-text">
|
||||
{{
|
||||
$gettext('This podcast is automatically synchronized with a playlist. Episodes cannot be manually added or removed via this panel.')
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template #actions>
|
||||
<router-link
|
||||
class="btn btn-secondary"
|
||||
:to="{name: 'stations:podcasts:index'}"
|
||||
|
@ -39,10 +45,11 @@
|
|||
</router-link>
|
||||
|
||||
<add-button
|
||||
v-if="podcastIsManual"
|
||||
:text="$gettext('Add Episode')"
|
||||
@click="doCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<data-table
|
||||
id="station_podcast_episodes"
|
||||
|
@ -74,12 +81,18 @@
|
|||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(podcast_media)="{item}">
|
||||
<template #cell(media)="{item}">
|
||||
<template v-if="item.media">
|
||||
<span>{{ item.media.original_name }}</span>
|
||||
<br>
|
||||
<small>{{ item.media.length_text }}</small>
|
||||
</template>
|
||||
<template v-else-if="item.playlist_media">
|
||||
<span>{{ item.playlist_media.text }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
</template>
|
||||
</template>
|
||||
<template #cell(is_published)="{item}">
|
||||
<span v-if="item.is_published">
|
||||
|
@ -106,6 +119,7 @@
|
|||
{{ $gettext('Edit') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="podcastIsManual"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="doDelete(item.links.self)"
|
||||
|
@ -115,14 +129,12 @@
|
|||
</div>
|
||||
</template>
|
||||
</data-table>
|
||||
</section>
|
||||
</card-page>
|
||||
|
||||
<edit-modal
|
||||
ref="$editEpisodeModal"
|
||||
:podcast="podcast"
|
||||
:create-url="podcast.links.episodes"
|
||||
:new-art-url="podcast.links.episode_new_art"
|
||||
:new-media-url="podcast.links.episode_new_media"
|
||||
:podcast-id="podcast.id"
|
||||
@relist="relist"
|
||||
/>
|
||||
</template>
|
||||
|
@ -134,7 +146,7 @@ import Icon from '~/components/Common/Icon.vue';
|
|||
import AlbumArt from '~/components/Common/AlbumArt.vue';
|
||||
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {ref} from "vue";
|
||||
import {computed, ref} from "vue";
|
||||
import AddButton from "~/components/Common/AddButton.vue";
|
||||
import {IconChevronLeft} from "~/components/Common/icons";
|
||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
|
@ -143,6 +155,7 @@ import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
|
|||
import {ApiPodcast} from "~/entities/ApiInterfaces.ts";
|
||||
import useHasEditModal from "~/functions/useHasEditModal.ts";
|
||||
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
podcast: ApiPodcast
|
||||
|
@ -168,7 +181,7 @@ const fields: DataTableField[] = [
|
|||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'podcast_media',
|
||||
key: 'media',
|
||||
label: $gettext('File Name'),
|
||||
sortable: false
|
||||
},
|
||||
|
@ -200,6 +213,10 @@ const fields: DataTableField[] = [
|
|||
}
|
||||
];
|
||||
|
||||
const podcastIsManual = computed(() => {
|
||||
return props.podcast.source == 'manual';
|
||||
});
|
||||
|
||||
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
|
||||
|
||||
const $datatable = ref<DataTableTemplateRef>(null);
|
||||
|
|
|
@ -46,11 +46,17 @@
|
|||
target="_blank"
|
||||
>{{ $gettext('RSS Feed') }}</a>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="badges"
|
||||
>
|
||||
<span class="badge text-bg-info">
|
||||
<div class="badges">
|
||||
<span
|
||||
v-if="item.source === 'playlist'"
|
||||
class="badge text-bg-info"
|
||||
>
|
||||
{{ $gettext('Playlist-Based') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!item.is_published"
|
||||
class="badge text-bg-info"
|
||||
>
|
||||
{{ $gettext('Unpublished') }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
/>
|
||||
|
||||
<episode-form-media
|
||||
v-if="podcastIsManual"
|
||||
v-model="form.media_file"
|
||||
:record-has-media="record.has_media"
|
||||
:new-media-url="newMediaUrl"
|
||||
|
@ -44,20 +45,18 @@ import Tabs from "~/components/Common/Tabs.vue";
|
|||
|
||||
const props = defineProps({
|
||||
...baseEditModalProps,
|
||||
podcastId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
newArtUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
newMediaUrl: {
|
||||
type: String,
|
||||
podcast: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const newArtUrl = computed(() => props.podcast.links.episode_new_art);
|
||||
const newMediaUrl = computed(() => props.podcast.links.episode_new_media);
|
||||
const podcastIsManual = computed(() => {
|
||||
return props.podcast.source == 'manual';
|
||||
});
|
||||
|
||||
const emit = defineEmits(['relist']);
|
||||
|
||||
const $modal = ref<ModalFormTemplateRef>(null);
|
||||
|
|
|
@ -15,6 +15,10 @@
|
|||
:language-options="languageOptions"
|
||||
/>
|
||||
|
||||
<podcast-form-source
|
||||
:form="form"
|
||||
/>
|
||||
|
||||
<podcast-common-artwork
|
||||
v-model="form.artwork_file"
|
||||
:artwork-src="record.links.art"
|
||||
|
@ -26,6 +30,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import PodcastFormBasicInfo from './PodcastForm/BasicInfo.vue';
|
||||
import PodcastFormSource from './PodcastForm/Source.vue';
|
||||
import PodcastCommonArtwork from './Common/Artwork.vue';
|
||||
import mergeExisting from "~/functions/mergeExisting";
|
||||
import {baseEditModalProps, ModalFormTemplateRef, useBaseEditModal} from "~/functions/useBaseEditModal";
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
<tab
|
||||
:label="$gettext('Source')"
|
||||
:item-header-class="tabClass"
|
||||
>
|
||||
<div class="row g-3">
|
||||
<form-group-multi-check
|
||||
id="edit_form_source"
|
||||
class="col-md-12"
|
||||
:field="v$.source"
|
||||
:options="sourceOptions"
|
||||
stacked
|
||||
radio
|
||||
:label="$gettext('Source')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-show="form.source === 'playlist'"
|
||||
class="card mb-3"
|
||||
role="region"
|
||||
>
|
||||
<div class="card-header text-bg-primary">
|
||||
<h2 class="card-title">
|
||||
{{ $gettext('Playlist-Based Podcast') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
{{
|
||||
$gettext('Playlist-based podcasts will automatically sync with the contents of a playlist, creating new podcast episodes for any media added to the playlist.')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<loading :loading="playlistsLoading">
|
||||
<div class="row g-3 mb-3">
|
||||
<form-group-select
|
||||
id="form_edit_playlist_id"
|
||||
class="col-md-12"
|
||||
:field="v$.playlist_id"
|
||||
:options="playlistOptions"
|
||||
:label="$gettext('Select Playlist')"
|
||||
/>
|
||||
|
||||
<form-group-checkbox
|
||||
id="form_edit_playlist_auto_publish"
|
||||
class="col-md-12"
|
||||
:field="v$.playlist_auto_publish"
|
||||
:label="$gettext('Automatically Publish New Episodes')"
|
||||
:description="$gettext('Whether new episodes should be marked as published or held for review as unpublished.')"
|
||||
/>
|
||||
</div>
|
||||
</loading>
|
||||
</div>
|
||||
</section>
|
||||
</tab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FormGroupSelect from "~/components/Form/FormGroupSelect.vue";
|
||||
import {useVModel} from "@vueuse/core";
|
||||
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||
import {required} from "@vuelidate/validators";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
|
||||
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
||||
import {useTranslate} from "~/vendor/gettext.ts";
|
||||
import {onMounted, ref, shallowRef} from "vue";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import objectToFormOptions from "~/functions/objectToFormOptions.ts";
|
||||
import {getStationApiUrl} from "~/router.ts";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
|
||||
const props = defineProps({
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:form']);
|
||||
const form = useVModel(props, 'form', emit);
|
||||
|
||||
const {v$, tabClass} = useVuelidateOnFormTab(
|
||||
{
|
||||
source: {required},
|
||||
playlist_id: {},
|
||||
playlist_auto_publish: {}
|
||||
},
|
||||
form,
|
||||
{
|
||||
source: 'manual',
|
||||
playlist_id: null,
|
||||
playlist_auto_publish: true,
|
||||
}
|
||||
);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const sourceOptions = [
|
||||
{
|
||||
value: 'manual',
|
||||
text: $gettext('Manually Add Episodes'),
|
||||
description: $gettext('Create podcast episodes independent of your station\'s media collection.')
|
||||
},
|
||||
{
|
||||
value: 'playlist',
|
||||
text: $gettext('Synchronize with Playlist'),
|
||||
description: $gettext('Automatically create new podcast episodes when media is added to a specified playlist.')
|
||||
}
|
||||
];
|
||||
|
||||
const playlistsLoading = ref(true);
|
||||
const playlistOptions = shallowRef([]);
|
||||
|
||||
const {axios} = useAxios();
|
||||
const playlistsApiUrl = getStationApiUrl('/podcasts/playlists');
|
||||
|
||||
const loadPlaylists = () => {
|
||||
axios.get(playlistsApiUrl.value).then((resp) => {
|
||||
playlistOptions.value = objectToFormOptions(resp.data);
|
||||
}).finally(() => {
|
||||
playlistsLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(loadPlaylists);
|
||||
</script>
|
|
@ -604,6 +604,9 @@ export interface ApiNowPlayingStationRemote {
|
|||
export type ApiPodcast = HasLinks & {
|
||||
id?: string;
|
||||
storage_location_id?: number;
|
||||
source?: string;
|
||||
playlist_id?: number | null;
|
||||
playlist_auto_publish?: boolean;
|
||||
title?: string;
|
||||
link?: string | null;
|
||||
description?: string;
|
||||
|
@ -637,7 +640,9 @@ export type ApiPodcastEpisode = HasLinks & {
|
|||
is_published?: boolean;
|
||||
publish_at?: number | null;
|
||||
has_media?: boolean;
|
||||
media?: ApiPodcastMedia;
|
||||
playlist_media_id?: string | null;
|
||||
playlist_media?: ApiSong | null;
|
||||
media?: ApiPodcastMedia | null;
|
||||
has_custom_art?: boolean;
|
||||
art?: string | null;
|
||||
art_updated_at?: number;
|
||||
|
|
|
@ -8,6 +8,7 @@ use App\Container\EntityManagerAwareTrait;
|
|||
use App\Controller\Api\Traits\CanSearchResults;
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\ApiGenerator\PodcastEpisodeApiGenerator;
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Entity\PodcastEpisode;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
|
@ -33,11 +34,16 @@ final class ListEpisodesAction implements SingleActionInterface
|
|||
->from(PodcastEpisode::class, 'e')
|
||||
->join('e.podcast', 'p')
|
||||
->leftJoin('e.media', 'pm')
|
||||
->leftJoin('e.playlist_media', 'sm')
|
||||
->where('e.podcast = :podcast')
|
||||
->setParameter('podcast', $podcast)
|
||||
->andWhere('e.publish_at IS NULL OR e.publish_at <= :publishTime')
|
||||
->setParameter('publishTime', time())
|
||||
->andWhere('pm.id IS NOT NULL')
|
||||
->andWhere(
|
||||
'(p.source = :sourceManual AND pm.id IS NOT NULL) OR (p.source = :sourcePlaylist AND sm.id IS NOT NULL)'
|
||||
)
|
||||
->setParameter('sourceManual', PodcastSources::Manual->value)
|
||||
->setParameter('sourcePlaylist', PodcastSources::Playlist->value)
|
||||
->orderBy('e.publish_at', 'DESC');
|
||||
|
||||
$queryBuilder = $this->searchQueryBuilder(
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations\Podcasts\Episodes\Media;
|
|||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Api\Error;
|
||||
use App\Entity\Api\Status;
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Entity\PodcastEpisode;
|
||||
use App\Entity\PodcastMedia;
|
||||
use App\Entity\Repository\PodcastEpisodeRepository;
|
||||
|
@ -14,6 +15,7 @@ use App\Http\Response;
|
|||
use App\Http\ServerRequest;
|
||||
use App\OpenApi;
|
||||
use App\Utilities\Types;
|
||||
use InvalidArgumentException;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
|
@ -59,12 +61,14 @@ final class DeleteMediaAction implements SingleActionInterface
|
|||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$episodeId = Types::string($params['episode_id'] ?? null);
|
||||
$podcast = $request->getPodcast();
|
||||
|
||||
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
|
||||
$request->getPodcast(),
|
||||
$episodeId
|
||||
);
|
||||
if ($podcast->getSource() !== PodcastSources::Manual) {
|
||||
throw new InvalidArgumentException('Media cannot be manually set on this podcast.');
|
||||
}
|
||||
|
||||
$episodeId = Types::string($params['episode_id'] ?? null);
|
||||
$episode = $this->episodeRepo->fetchEpisodeForPodcast($podcast, $episodeId);
|
||||
|
||||
if (!($episode instanceof PodcastEpisode)) {
|
||||
return $response->withStatus(404)
|
||||
|
|
|
@ -6,9 +6,11 @@ namespace App\Controller\Api\Stations\Podcasts\Episodes\Media;
|
|||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Api\Error;
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Entity\PodcastEpisode;
|
||||
use App\Entity\PodcastMedia;
|
||||
use App\Entity\Repository\PodcastEpisodeRepository;
|
||||
use App\Entity\StationMedia;
|
||||
use App\Flysystem\StationFilesystems;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
|
@ -68,26 +70,46 @@ final class GetMediaAction implements SingleActionInterface
|
|||
$episodeId = Types::string($params['episode_id'] ?? null);
|
||||
|
||||
$station = $request->getStation();
|
||||
$podcast = $request->getPodcast();
|
||||
|
||||
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
|
||||
$request->getPodcast(),
|
||||
$podcast,
|
||||
$episodeId
|
||||
);
|
||||
|
||||
if ($episode instanceof PodcastEpisode) {
|
||||
$podcastMedia = $episode->getMedia();
|
||||
switch ($podcast->getSource()) {
|
||||
case PodcastSources::Playlist:
|
||||
$playlistMedia = $episode->getPlaylistMedia();
|
||||
|
||||
if ($podcastMedia instanceof PodcastMedia) {
|
||||
$fsPodcasts = $this->stationFilesystems->getPodcastsFilesystem($station);
|
||||
if ($playlistMedia instanceof StationMedia) {
|
||||
$fsMedia = $this->stationFilesystems->getMediaFilesystem($station);
|
||||
|
||||
$path = $podcastMedia->getPath();
|
||||
set_time_limit(600);
|
||||
return $response->streamFilesystemFile(
|
||||
$fsMedia,
|
||||
$playlistMedia->getPath()
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
if ($fsPodcasts->fileExists($path)) {
|
||||
return $response->streamFilesystemFile(
|
||||
$fsPodcasts,
|
||||
$path,
|
||||
$podcastMedia->getOriginalName()
|
||||
);
|
||||
}
|
||||
case PodcastSources::Manual:
|
||||
$podcastMedia = $episode->getMedia();
|
||||
|
||||
if ($podcastMedia instanceof PodcastMedia) {
|
||||
$fsPodcasts = $this->stationFilesystems->getPodcastsFilesystem($station);
|
||||
|
||||
$path = $podcastMedia->getPath();
|
||||
|
||||
if ($fsPodcasts->fileExists($path)) {
|
||||
return $response->streamFilesystemFile(
|
||||
$fsPodcasts,
|
||||
$path,
|
||||
$podcastMedia->getOriginalName()
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations\Podcasts\Episodes\Media;
|
|||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Api\Error;
|
||||
use App\Entity\Api\Status;
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Entity\Repository\PodcastEpisodeRepository;
|
||||
use App\Flysystem\StationFilesystems;
|
||||
use App\Http\Response;
|
||||
|
@ -14,6 +15,7 @@ use App\Http\ServerRequest;
|
|||
use App\OpenApi;
|
||||
use App\Service\Flow;
|
||||
use App\Utilities\Types;
|
||||
use InvalidArgumentException;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
|
@ -60,20 +62,22 @@ final class PostMediaAction implements SingleActionInterface
|
|||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$episodeId = Types::stringOrNull($params['episode_id'] ?? null, true);
|
||||
|
||||
$podcast = $request->getPodcast();
|
||||
$station = $request->getStation();
|
||||
|
||||
if ($podcast->getSource() !== PodcastSources::Manual) {
|
||||
throw new InvalidArgumentException('Media cannot be manually set on this podcast.');
|
||||
}
|
||||
|
||||
$episodeId = Types::stringOrNull($params['episode_id'] ?? null, true);
|
||||
|
||||
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
|
||||
if ($flowResponse instanceof ResponseInterface) {
|
||||
return $flowResponse;
|
||||
}
|
||||
|
||||
if (null !== $episodeId) {
|
||||
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
|
||||
$request->getPodcast(),
|
||||
$episodeId
|
||||
);
|
||||
$episode = $this->episodeRepo->fetchEpisodeForPodcast($podcast, $episodeId);
|
||||
|
||||
if (null === $episode) {
|
||||
return $response->withStatus(404)
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Stations\Podcasts;
|
||||
|
||||
use App\Container\EntityManagerAwareTrait;
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Enums\PlaylistSources;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class PlaylistsAction implements SingleActionInterface
|
||||
{
|
||||
use EntityManagerAwareTrait;
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$playlistsRaw = $this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT sp.id, sp.name
|
||||
FROM App\Entity\StationPlaylist sp
|
||||
WHERE sp.station = :station
|
||||
AND sp.source = :sourceSongs
|
||||
DQL
|
||||
)->setParameter('station', $request->getStation())
|
||||
->setParameter('sourceSongs', PlaylistSources::Songs->value)
|
||||
->getArrayResult();
|
||||
|
||||
return $response->withJson(
|
||||
array_column($playlistsRaw, 'name', 'id')
|
||||
);
|
||||
}
|
||||
}
|
|
@ -302,6 +302,12 @@ final class PodcastsController extends AbstractApiCrudController
|
|||
unset($data['categories']);
|
||||
}
|
||||
|
||||
if (isset($data['playlist_id'])) {
|
||||
$data['playlist'] = $data['playlist_id'];
|
||||
unset($data['playlist_id']);
|
||||
}
|
||||
|
||||
|
||||
$record = parent::fromArray($data, $record, $context);
|
||||
|
||||
if (null !== $newCategories) {
|
||||
|
|
|
@ -15,7 +15,7 @@ use RuntimeException;
|
|||
#[
|
||||
ORM\Entity(readOnly: true),
|
||||
ORM\Table(name: 'analytics'),
|
||||
ORM\Index(columns: ['type', 'moment'], name: 'search_idx'),
|
||||
ORM\Index(name: 'search_idx', columns: ['type', 'moment']),
|
||||
ORM\UniqueConstraint(name: 'stats_unique_idx', columns: ['station_id', 'type', 'moment'])
|
||||
]
|
||||
class Analytics implements IdentifiableEntityInterface
|
||||
|
|
|
@ -21,6 +21,15 @@ final class Podcast
|
|||
#[OA\Property]
|
||||
public int $storage_location_id;
|
||||
|
||||
#[OA\Property]
|
||||
public string $source;
|
||||
|
||||
#[OA\Property]
|
||||
public ?int $playlist_id = null;
|
||||
|
||||
#[OA\Property]
|
||||
public bool $playlist_auto_publish = false;
|
||||
|
||||
#[OA\Property]
|
||||
public string $title;
|
||||
|
||||
|
|
|
@ -43,7 +43,13 @@ final class PodcastEpisode
|
|||
public bool $has_media = false;
|
||||
|
||||
#[OA\Property]
|
||||
public PodcastMedia $media;
|
||||
public ?string $playlist_media_id = null;
|
||||
|
||||
#[OA\Property]
|
||||
public ?Song $playlist_media = null;
|
||||
|
||||
#[OA\Property]
|
||||
public ?PodcastMedia $media = null;
|
||||
|
||||
#[OA\Property]
|
||||
public bool $has_custom_art = false;
|
||||
|
|
|
@ -38,6 +38,10 @@ final class PodcastApiGenerator
|
|||
$return->id = $record->getIdRequired();
|
||||
$return->storage_location_id = $record->getStorageLocation()->getIdRequired();
|
||||
|
||||
$return->source = $record->getSource()->value;
|
||||
$return->playlist_id = $record->getPlaylist()?->getIdRequired();
|
||||
$return->playlist_auto_publish = $record->playlistAutoPublish();
|
||||
|
||||
$return->title = $record->getTitle();
|
||||
$return->link = $record->getLink();
|
||||
|
||||
|
|
|
@ -6,13 +6,20 @@ namespace App\Entity\ApiGenerator;
|
|||
|
||||
use App\Entity\Api\PodcastEpisode as ApiPodcastEpisode;
|
||||
use App\Entity\Api\PodcastMedia as ApiPodcastMedia;
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Entity\PodcastEpisode;
|
||||
use App\Entity\PodcastMedia;
|
||||
use App\Entity\StationMedia;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Utilities\Strings;
|
||||
|
||||
final class PodcastEpisodeApiGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SongApiGenerator $songApiGen
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
PodcastEpisode $record,
|
||||
ServerRequest $request
|
||||
|
@ -34,21 +41,45 @@ final class PodcastEpisodeApiGenerator
|
|||
$return->created_at = $record->getCreatedAt();
|
||||
$return->publish_at = $record->getPublishAt();
|
||||
|
||||
$mediaRow = $record->getMedia();
|
||||
$return->has_media = ($mediaRow instanceof PodcastMedia);
|
||||
if ($mediaRow instanceof PodcastMedia) {
|
||||
$media = new ApiPodcastMedia();
|
||||
$media->id = $mediaRow->getId();
|
||||
$media->original_name = $mediaRow->getOriginalName();
|
||||
$media->length = $mediaRow->getLength();
|
||||
$media->length_text = $mediaRow->getLengthText();
|
||||
$media->path = $mediaRow->getPath();
|
||||
switch ($podcast->getSource()) {
|
||||
case PodcastSources::Playlist:
|
||||
$return->media = null;
|
||||
|
||||
$return->has_media = true;
|
||||
$return->media = $media;
|
||||
} else {
|
||||
$return->has_media = false;
|
||||
$return->media = new ApiPodcastMedia();
|
||||
$playlistMediaRow = $record->getPlaylistMedia();
|
||||
if ($playlistMediaRow instanceof StationMedia) {
|
||||
$return->has_media = true;
|
||||
|
||||
$return->playlist_media = $this->songApiGen->__invoke($playlistMediaRow);
|
||||
$return->playlist_media_id = $playlistMediaRow->getUniqueId();
|
||||
} else {
|
||||
$return->has_media = false;
|
||||
|
||||
$return->playlist_media = null;
|
||||
$return->playlist_media_id = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case PodcastSources::Manual:
|
||||
$return->playlist_media = null;
|
||||
$return->playlist_media_id = null;
|
||||
|
||||
$mediaRow = $record->getMedia();
|
||||
$return->has_media = ($mediaRow instanceof PodcastMedia);
|
||||
if ($mediaRow instanceof PodcastMedia) {
|
||||
$media = new ApiPodcastMedia();
|
||||
$media->id = $mediaRow->getId();
|
||||
$media->original_name = $mediaRow->getOriginalName();
|
||||
$media->length = $mediaRow->getLength();
|
||||
$media->length_text = $mediaRow->getLengthText();
|
||||
$media->path = $mediaRow->getPath();
|
||||
|
||||
$return->has_media = true;
|
||||
$return->media = $media;
|
||||
} else {
|
||||
$return->has_media = false;
|
||||
$return->media = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$return->is_published = $record->isPublished();
|
||||
|
|
|
@ -11,7 +11,7 @@ use Doctrine\ORM\Mapping as ORM;
|
|||
#[
|
||||
ORM\Entity(readOnly: true),
|
||||
ORM\Table(name: 'audit_log'),
|
||||
ORM\Index(columns: ['class', 'user', 'identifier'], name: 'idx_search')
|
||||
ORM\Index(name: 'idx_search', columns: ['class', 'user', 'identifier'])
|
||||
]
|
||||
class AuditLog implements IdentifiableEntityInterface
|
||||
{
|
||||
|
|
|
@ -6,6 +6,8 @@ namespace App\Entity;
|
|||
|
||||
use App\Entity\Interfaces\IdentifiableEntityInterface;
|
||||
use App\Utilities\File;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Stringable;
|
||||
|
@ -45,6 +47,15 @@ class CustomField implements Stringable, IdentifiableEntityInterface
|
|||
]
|
||||
protected ?string $auto_assign = null;
|
||||
|
||||
/** @var Collection<int, StationMediaCustomField> */
|
||||
#[ORM\OneToMany(targetEntity: StationMediaCustomField::class, mappedBy: 'field')]
|
||||
protected Collection $media_fields;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->media_fields = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Enums;
|
||||
|
||||
enum PodcastSources: string
|
||||
{
|
||||
case Manual = 'manual';
|
||||
case Playlist = 'playlist';
|
||||
}
|
|
@ -10,10 +10,10 @@ use NowPlaying\Result\Client;
|
|||
#[
|
||||
ORM\Entity,
|
||||
ORM\Table(name: 'listener'),
|
||||
ORM\Index(columns: ['timestamp_end', 'timestamp_start'], name: 'idx_timestamps'),
|
||||
ORM\Index(columns: ['location_country'], name: 'idx_statistics_country'),
|
||||
ORM\Index(columns: ['device_os_family'], name: 'idx_statistics_os'),
|
||||
ORM\Index(columns: ['device_browser_family'], name: 'idx_statistics_browser')
|
||||
ORM\Index(name: 'idx_timestamps', columns: ['timestamp_end', 'timestamp_start']),
|
||||
ORM\Index(name: 'idx_statistics_country', columns: ['location_country']),
|
||||
ORM\Index(name: 'idx_statistics_os', columns: ['device_os_family']),
|
||||
ORM\Index(name: 'idx_statistics_browser', columns: ['device_browser_family'])
|
||||
]
|
||||
class Listener implements
|
||||
Interfaces\IdentifiableEntityInterface,
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20240221151753 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add ability for podcasts to sync from playlists.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE podcast ADD playlist_id INT DEFAULT NULL, ADD source VARCHAR(50) NOT NULL, ADD playlist_auto_publish TINYINT(1) NOT NULL');
|
||||
$this->addSql('ALTER TABLE podcast ADD CONSTRAINT FK_D7E805BD6BBD148 FOREIGN KEY (playlist_id) REFERENCES station_playlists (id) ON DELETE CASCADE');
|
||||
$this->addSql('CREATE INDEX IDX_D7E805BD6BBD148 ON podcast (playlist_id)');
|
||||
$this->addSql('ALTER TABLE podcast_episode ADD playlist_media_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE podcast_episode ADD CONSTRAINT FK_77EB2BD017421B18 FOREIGN KEY (playlist_media_id) REFERENCES station_media (id) ON DELETE CASCADE');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_77EB2BD017421B18 ON podcast_episode (playlist_media_id)');
|
||||
|
||||
$this->addSql(
|
||||
<<<'SQL'
|
||||
UPDATE podcast SET source='manual'
|
||||
SQL
|
||||
);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE podcast DROP FOREIGN KEY FK_D7E805BD6BBD148');
|
||||
$this->addSql('DROP INDEX IDX_D7E805BD6BBD148 ON podcast');
|
||||
$this->addSql('ALTER TABLE podcast DROP playlist_id, DROP source, DROP playlist_auto_publish');
|
||||
$this->addSql('ALTER TABLE podcast_episode DROP FOREIGN KEY FK_77EB2BD017421B18');
|
||||
$this->addSql('DROP INDEX UNIQ_77EB2BD017421B18 ON podcast_episode');
|
||||
$this->addSql('ALTER TABLE podcast_episode DROP playlist_media_id');
|
||||
}
|
||||
}
|
|
@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Normalizer\Attributes\DeepNormalize;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
@ -25,6 +27,20 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
|
|||
#[ORM\JoinColumn(name: 'storage_location_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
protected StorageLocation $storage_location;
|
||||
|
||||
#[ORM\Column(nullable: false, insertable: false, updatable: false)]
|
||||
protected int $storage_location_id;
|
||||
|
||||
#[DeepNormalize(true)]
|
||||
#[ORM\ManyToOne(inversedBy: 'podcasts')]
|
||||
#[ORM\JoinColumn(name: 'playlist_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
protected ?StationPlaylist $playlist = null;
|
||||
|
||||
#[ORM\Column(nullable: true, insertable: false, updatable: false)]
|
||||
protected ?int $playlist_id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 50, enumType: PodcastSources::class)]
|
||||
protected PodcastSources $source = PodcastSources::Manual;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
protected string $title;
|
||||
|
@ -51,12 +67,15 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
|
|||
#[Attributes\AuditIgnore]
|
||||
protected int $art_updated_at = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
protected bool $playlist_auto_publish = true;
|
||||
|
||||
/** @var Collection<int, PodcastCategory> */
|
||||
#[ORM\OneToMany(mappedBy: 'podcast', targetEntity: PodcastCategory::class)]
|
||||
#[ORM\OneToMany(targetEntity: PodcastCategory::class, mappedBy: 'podcast')]
|
||||
protected Collection $categories;
|
||||
|
||||
/** @var Collection<int, PodcastEpisode> */
|
||||
#[ORM\OneToMany(mappedBy: 'podcast', targetEntity: PodcastEpisode::class, fetch: 'EXTRA_LAZY')]
|
||||
#[ORM\OneToMany(targetEntity: PodcastEpisode::class, mappedBy: 'podcast', fetch: 'EXTRA_LAZY')]
|
||||
protected Collection $episodes;
|
||||
|
||||
public function __construct(StorageLocation $storageLocation)
|
||||
|
@ -72,6 +91,26 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
|
|||
return $this->storage_location;
|
||||
}
|
||||
|
||||
public function getPlaylist(): ?StationPlaylist
|
||||
{
|
||||
return $this->playlist;
|
||||
}
|
||||
|
||||
public function setPlaylist(?StationPlaylist $playlist): void
|
||||
{
|
||||
$this->playlist = $playlist;
|
||||
}
|
||||
|
||||
public function getSource(): PodcastSources
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
|
||||
public function setSource(PodcastSources $source): void
|
||||
{
|
||||
$this->source = $source;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
|
@ -156,6 +195,16 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function playlistAutoPublish(): bool
|
||||
{
|
||||
return $this->playlist_auto_publish;
|
||||
}
|
||||
|
||||
public function setPlaylistAutoPublish(bool $playlist_auto_publish): void
|
||||
{
|
||||
$this->playlist_auto_publish = $playlist_auto_publish;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PodcastCategory>
|
||||
*/
|
||||
|
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Entity\Interfaces\IdentifiableEntityInterface;
|
||||
use App\Entity\Traits;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
|
@ -25,6 +25,13 @@ class PodcastEpisode implements IdentifiableEntityInterface
|
|||
#[ORM\JoinColumn(name: 'podcast_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
protected Podcast $podcast;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'podcast_episodes')]
|
||||
#[ORM\JoinColumn(name: 'playlist_media_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
protected ?StationMedia $playlist_media = null;
|
||||
|
||||
#[ORM\Column(nullable: true, insertable: false, updatable: false)]
|
||||
protected ?int $playlist_media_id = null;
|
||||
|
||||
#[ORM\OneToOne(mappedBy: 'episode')]
|
||||
protected ?PodcastMedia $media = null;
|
||||
|
||||
|
@ -73,6 +80,16 @@ class PodcastEpisode implements IdentifiableEntityInterface
|
|||
return $this->media;
|
||||
}
|
||||
|
||||
public function getPlaylistMedia(): ?StationMedia
|
||||
{
|
||||
return $this->playlist_media;
|
||||
}
|
||||
|
||||
public function setPlaylistMedia(?StationMedia $playlist_media): void
|
||||
{
|
||||
$this->playlist_media = $playlist_media;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
|
@ -168,10 +185,9 @@ class PodcastEpisode implements IdentifiableEntityInterface
|
|||
return false;
|
||||
}
|
||||
|
||||
if ($this->getMedia() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return match ($this->getPodcast()->getSource()) {
|
||||
PodcastSources::Manual => ($this->getMedia() !== null),
|
||||
PodcastSources::Playlist => ($this->getPlaylistMedia() !== null)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ class Relay implements IdentifiableEntityInterface
|
|||
protected int $updated_at;
|
||||
|
||||
/** @var Collection<int, StationRemote> */
|
||||
#[ORM\OneToMany(mappedBy: 'relay', targetEntity: StationRemote::class)]
|
||||
#[ORM\OneToMany(targetEntity: StationRemote::class, mappedBy: 'relay')]
|
||||
protected Collection $remotes;
|
||||
|
||||
public function __construct(string $baseUrl)
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace App\Entity\Repository;
|
||||
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Entity\Podcast;
|
||||
use App\Entity\PodcastEpisode;
|
||||
use App\Entity\PodcastMedia;
|
||||
|
@ -17,6 +18,7 @@ use App\Media\MetadataManager;
|
|||
use InvalidArgumentException;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
use League\Flysystem\UnableToRetrieveMetadata;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* @extends Repository<PodcastEpisode>
|
||||
|
@ -124,6 +126,11 @@ final class PodcastEpisodeRepository extends Repository
|
|||
?ExtendedFilesystemInterface $fs = null
|
||||
): void {
|
||||
$podcast = $episode->getPodcast();
|
||||
|
||||
if ($podcast->getSource() !== PodcastSources::Manual) {
|
||||
throw new LogicException('Cannot upload media to this podcast type.');
|
||||
}
|
||||
|
||||
$storageLocation = $podcast->getStorageLocation();
|
||||
|
||||
$fs ??= $this->storageLocationRepo->getAdapter($storageLocation)
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace App\Entity\Repository;
|
||||
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Entity\Podcast;
|
||||
use App\Entity\Station;
|
||||
use App\Exception\StorageLocationFullException;
|
||||
|
@ -47,11 +48,15 @@ final class PodcastRepository extends Repository
|
|||
SELECT DISTINCT p.id
|
||||
FROM App\Entity\PodcastEpisode pe
|
||||
JOIN pe.podcast p
|
||||
JOIN pe.media pm
|
||||
WHERE pm.id IS NOT NULL
|
||||
AND (pe.publish_at IS NULL OR pe.publish_at <= :time)
|
||||
LEFT JOIN pe.media pm
|
||||
LEFT JOIN pe.playlist_media sm
|
||||
WHERE
|
||||
((p.source = :sourceManual AND pm.id IS NOT NULL) OR (p.source = :sourcePlaylist AND sm.id IS NOT NULL))
|
||||
AND (pe.publish_at IS NULL OR pe.publish_at <= :time)
|
||||
DQL
|
||||
)->setParameter('time', time())
|
||||
->setParameter('sourceManual', PodcastSources::Manual->value)
|
||||
->setParameter('sourcePlaylist', PodcastSources::Playlist->value)
|
||||
->enableResultCache(60, 'podcast_ids_' . $station->getIdRequired())
|
||||
->getSingleColumnResult();
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ class Role implements JsonSerializable, Stringable, IdentifiableEntityInterface
|
|||
/** @var Collection<int, RolePermission> */
|
||||
#[
|
||||
OA\Property(type: "array", items: new OA\Items()),
|
||||
ORM\OneToMany(mappedBy: 'role', targetEntity: RolePermission::class)
|
||||
ORM\OneToMany(targetEntity: RolePermission::class, mappedBy: 'role')
|
||||
]
|
||||
protected Collection $permissions;
|
||||
|
||||
|
|
|
@ -10,9 +10,9 @@ use Doctrine\ORM\Mapping as ORM;
|
|||
#[
|
||||
ORM\Entity,
|
||||
ORM\Table(name: 'song_history'),
|
||||
ORM\Index(columns: ['is_visible'], name: 'idx_is_visible'),
|
||||
ORM\Index(columns: ['timestamp_start'], name: 'idx_timestamp_start'),
|
||||
ORM\Index(columns: ['timestamp_end'], name: 'idx_timestamp_end')
|
||||
ORM\Index(name: 'idx_is_visible', columns: ['is_visible']),
|
||||
ORM\Index(name: 'idx_timestamp_start', columns: ['timestamp_start']),
|
||||
ORM\Index(name: 'idx_timestamp_end', columns: ['timestamp_end'])
|
||||
]
|
||||
class SongHistory implements
|
||||
Interfaces\SongInterface,
|
||||
|
|
|
@ -33,7 +33,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
OA\Schema(schema: "Station", type: "object"),
|
||||
ORM\Entity,
|
||||
ORM\Table(name: 'station'),
|
||||
ORM\Index(columns: ['short_name'], name: 'idx_short_name'),
|
||||
ORM\Index(name: 'idx_short_name', columns: ['short_name']),
|
||||
ORM\HasLifecycleCallbacks,
|
||||
Attributes\Auditable,
|
||||
AppAssert\StationPortChecker,
|
||||
|
@ -285,7 +285,7 @@ class Station implements Stringable, IdentifiableEntityInterface
|
|||
|
||||
/** @var Collection<int, SongHistory> */
|
||||
#[
|
||||
ORM\OneToMany(mappedBy: 'station', targetEntity: SongHistory::class),
|
||||
ORM\OneToMany(targetEntity: SongHistory::class, mappedBy: 'station'),
|
||||
ORM\OrderBy(['timestamp_start' => 'desc'])
|
||||
]
|
||||
protected Collection $history;
|
||||
|
@ -333,7 +333,7 @@ class Station implements Stringable, IdentifiableEntityInterface
|
|||
protected ?StorageLocation $podcasts_storage_location = null;
|
||||
|
||||
/** @var Collection<int, StationStreamer> */
|
||||
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationStreamer::class)]
|
||||
#[ORM\OneToMany(targetEntity: StationStreamer::class, mappedBy: 'station')]
|
||||
protected Collection $streamers;
|
||||
|
||||
#[
|
||||
|
@ -353,41 +353,49 @@ class Station implements Stringable, IdentifiableEntityInterface
|
|||
protected ?string $fallback_path = null;
|
||||
|
||||
/** @var Collection<int, RolePermission> */
|
||||
#[ORM\OneToMany(mappedBy: 'station', targetEntity: RolePermission::class)]
|
||||
#[ORM\OneToMany(targetEntity: RolePermission::class, mappedBy: 'station')]
|
||||
protected Collection $permissions;
|
||||
|
||||
/** @var Collection<int, StationPlaylist> */
|
||||
#[
|
||||
ORM\OneToMany(mappedBy: 'station', targetEntity: StationPlaylist::class),
|
||||
ORM\OneToMany(targetEntity: StationPlaylist::class, mappedBy: 'station'),
|
||||
ORM\OrderBy(['type' => 'ASC', 'weight' => 'DESC'])
|
||||
]
|
||||
protected Collection $playlists;
|
||||
|
||||
/** @var Collection<int, StationMount> */
|
||||
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationMount::class)]
|
||||
#[ORM\OneToMany(targetEntity: StationMount::class, mappedBy: 'station')]
|
||||
protected Collection $mounts;
|
||||
|
||||
/** @var Collection<int, StationRemote> */
|
||||
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationRemote::class)]
|
||||
#[ORM\OneToMany(targetEntity: StationRemote::class, mappedBy: 'station')]
|
||||
protected Collection $remotes;
|
||||
|
||||
/** @var Collection<int, StationHlsStream> */
|
||||
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationHlsStream::class)]
|
||||
#[ORM\OneToMany(targetEntity: StationHlsStream::class, mappedBy: 'station')]
|
||||
protected Collection $hls_streams;
|
||||
|
||||
/** @var Collection<int, StationWebhook> */
|
||||
#[ORM\OneToMany(
|
||||
mappedBy: 'station',
|
||||
targetEntity: StationWebhook::class,
|
||||
mappedBy: 'station',
|
||||
cascade: ['persist'],
|
||||
fetch: 'EXTRA_LAZY'
|
||||
)]
|
||||
protected Collection $webhooks;
|
||||
|
||||
/** @var Collection<int, StationStreamerBroadcast> */
|
||||
#[ORM\OneToMany(targetEntity: StationStreamerBroadcast::class, mappedBy: 'station')]
|
||||
protected Collection $streamer_broadcasts;
|
||||
|
||||
/** @var Collection<int, SftpUser> */
|
||||
#[ORM\OneToMany(mappedBy: 'station', targetEntity: SftpUser::class)]
|
||||
#[ORM\OneToMany(targetEntity: SftpUser::class, mappedBy: 'station')]
|
||||
protected Collection $sftp_users;
|
||||
|
||||
/** @var Collection<int, StationRequest> */
|
||||
#[ORM\OneToMany(targetEntity: StationRequest::class, mappedBy: 'station')]
|
||||
protected Collection $requests;
|
||||
|
||||
#[
|
||||
ORM\ManyToOne,
|
||||
ORM\JoinColumn(name: 'current_song_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL'),
|
||||
|
@ -408,7 +416,9 @@ class Station implements Stringable, IdentifiableEntityInterface
|
|||
$this->hls_streams = new ArrayCollection();
|
||||
$this->webhooks = new ArrayCollection();
|
||||
$this->streamers = new ArrayCollection();
|
||||
$this->streamer_broadcasts = new ArrayCollection();
|
||||
$this->sftp_users = new ArrayCollection();
|
||||
$this->requests = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
|
|
|
@ -23,7 +23,7 @@ use Symfony\Component\Serializer\Annotation as Serializer;
|
|||
OA\Schema(type: "object"),
|
||||
ORM\Entity,
|
||||
ORM\Table(name: 'station_media'),
|
||||
ORM\Index(columns: ['title', 'artist', 'album'], name: 'search_idx'),
|
||||
ORM\Index(name: 'search_idx', columns: ['title', 'artist', 'album']),
|
||||
ORM\UniqueConstraint(name: 'path_unique_idx', columns: ['path', 'storage_location_id'])
|
||||
]
|
||||
class StationMedia implements
|
||||
|
@ -193,22 +193,27 @@ class StationMedia implements
|
|||
/** @var Collection<int, StationPlaylistMedia> */
|
||||
#[
|
||||
OA\Property(type: "array", items: new OA\Items()),
|
||||
ORM\OneToMany(mappedBy: 'media', targetEntity: StationPlaylistMedia::class),
|
||||
ORM\OneToMany(targetEntity: StationPlaylistMedia::class, mappedBy: 'media'),
|
||||
DeepNormalize(true),
|
||||
Serializer\MaxDepth(1)
|
||||
]
|
||||
protected Collection $playlists;
|
||||
|
||||
/** @var Collection<int, StationMediaCustomField> */
|
||||
#[ORM\OneToMany(mappedBy: 'media', targetEntity: StationMediaCustomField::class)]
|
||||
#[ORM\OneToMany(targetEntity: StationMediaCustomField::class, mappedBy: 'media')]
|
||||
protected Collection $custom_fields;
|
||||
|
||||
/** @var Collection<int, PodcastEpisode> */
|
||||
#[ORM\OneToMany(targetEntity: PodcastEpisode::class, mappedBy: 'playlist_media')]
|
||||
protected Collection $podcast_episodes;
|
||||
|
||||
public function __construct(StorageLocation $storageLocation, string $path)
|
||||
{
|
||||
$this->storage_location = $storageLocation;
|
||||
|
||||
$this->playlists = new ArrayCollection();
|
||||
$this->custom_fields = new ArrayCollection();
|
||||
$this->podcast_episodes = new ArrayCollection();
|
||||
|
||||
$this->setPath($path);
|
||||
$this->generateUniqueId();
|
||||
|
@ -445,6 +450,14 @@ class StationMedia implements
|
|||
$this->custom_fields = $customFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PodcastEpisode>
|
||||
*/
|
||||
public function getPodcastEpisodes(): Collection
|
||||
{
|
||||
return $this->podcast_episodes;
|
||||
}
|
||||
|
||||
public static function needsReprocessing(int $fileModifiedTime = 0, int $dbModifiedTime = 0): bool
|
||||
{
|
||||
return $fileModifiedTime > $dbModifiedTime;
|
||||
|
|
|
@ -16,7 +16,7 @@ class StationMediaCustomField implements IdentifiableEntityInterface
|
|||
use Traits\HasAutoIncrementId;
|
||||
use Traits\TruncateStrings;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'metadata')]
|
||||
#[ORM\ManyToOne(inversedBy: 'custom_fields')]
|
||||
#[ORM\JoinColumn(name: 'media_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
protected StationMedia $media;
|
||||
|
||||
|
|
|
@ -175,26 +175,35 @@ class StationPlaylist implements
|
|||
|
||||
/** @var Collection<int, StationPlaylistMedia> */
|
||||
#[
|
||||
ORM\OneToMany(mappedBy: 'playlist', targetEntity: StationPlaylistMedia::class, fetch: 'EXTRA_LAZY'),
|
||||
ORM\OneToMany(targetEntity: StationPlaylistMedia::class, mappedBy: 'playlist', fetch: 'EXTRA_LAZY'),
|
||||
ORM\OrderBy(['weight' => 'ASC'])
|
||||
]
|
||||
protected Collection $media_items;
|
||||
|
||||
/** @var Collection<int, StationPlaylistFolder> */
|
||||
#[
|
||||
ORM\OneToMany(mappedBy: 'playlist', targetEntity: StationPlaylistFolder::class, fetch: 'EXTRA_LAZY')
|
||||
ORM\OneToMany(targetEntity: StationPlaylistFolder::class, mappedBy: 'playlist', fetch: 'EXTRA_LAZY')
|
||||
]
|
||||
protected Collection $folders;
|
||||
|
||||
/** @var Collection<int, StationSchedule> */
|
||||
#[
|
||||
OA\Property(type: "array", items: new OA\Items()),
|
||||
ORM\OneToMany(mappedBy: 'playlist', targetEntity: StationSchedule::class, fetch: 'EXTRA_LAZY'),
|
||||
ORM\OneToMany(targetEntity: StationSchedule::class, mappedBy: 'playlist', fetch: 'EXTRA_LAZY'),
|
||||
DeepNormalize(true),
|
||||
Serializer\MaxDepth(1)
|
||||
]
|
||||
protected Collection $schedule_items;
|
||||
|
||||
/** @var Collection<int, Podcast> */
|
||||
#[
|
||||
OA\Property(type: "array", items: new OA\Items()),
|
||||
ORM\OneToMany(targetEntity: Podcast::class, mappedBy: 'playlist', fetch: 'EXTRA_LAZY'),
|
||||
DeepNormalize(true),
|
||||
Serializer\MaxDepth(1)
|
||||
]
|
||||
protected Collection $podcasts;
|
||||
|
||||
public function __construct(Station $station)
|
||||
{
|
||||
$this->station = $station;
|
||||
|
@ -207,6 +216,7 @@ class StationPlaylist implements
|
|||
$this->media_items = new ArrayCollection();
|
||||
$this->folders = new ArrayCollection();
|
||||
$this->schedule_items = new ArrayCollection();
|
||||
$this->podcasts = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getStation(): Station
|
||||
|
@ -414,6 +424,14 @@ class StationPlaylist implements
|
|||
return $this->schedule_items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Podcast>
|
||||
*/
|
||||
public function getPodcasts(): Collection
|
||||
{
|
||||
return $this->podcasts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether a playlist is enabled and has content which can be scheduled by an AutoDJ scheduler.
|
||||
*
|
||||
|
|
|
@ -9,10 +9,10 @@ use Doctrine\ORM\Mapping as ORM;
|
|||
#[
|
||||
ORM\Entity,
|
||||
ORM\Table(name: 'station_queue'),
|
||||
ORM\Index(columns: ['is_played'], name: 'idx_is_played'),
|
||||
ORM\Index(columns: ['timestamp_played'], name: 'idx_timestamp_played'),
|
||||
ORM\Index(columns: ['sent_to_autodj'], name: 'idx_sent_to_autodj'),
|
||||
ORM\Index(columns: ['timestamp_cued'], name: 'idx_timestamp_cued')
|
||||
ORM\Index(name: 'idx_is_played', columns: ['is_played']),
|
||||
ORM\Index(name: 'idx_timestamp_played', columns: ['timestamp_played']),
|
||||
ORM\Index(name: 'idx_sent_to_autodj', columns: ['sent_to_autodj']),
|
||||
ORM\Index(name: 'idx_timestamp_cued', columns: ['timestamp_cued'])
|
||||
]
|
||||
class StationQueue implements
|
||||
Interfaces\SongInterface,
|
||||
|
|
|
@ -19,7 +19,7 @@ class StationRequest implements
|
|||
{
|
||||
use Traits\HasAutoIncrementId;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'media')]
|
||||
#[ORM\ManyToOne(inversedBy: 'requests')]
|
||||
#[ORM\JoinColumn(name: 'station_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
protected Station $station;
|
||||
|
||||
|
|
|
@ -101,16 +101,25 @@ class StationStreamer implements
|
|||
/** @var Collection<int, StationSchedule> */
|
||||
#[
|
||||
OA\Property(type: "array", items: new OA\Items()),
|
||||
ORM\OneToMany(mappedBy: 'streamer', targetEntity: StationSchedule::class),
|
||||
ORM\OneToMany(targetEntity: StationSchedule::class, mappedBy: 'streamer'),
|
||||
DeepNormalize(true),
|
||||
Serializer\MaxDepth(1)
|
||||
]
|
||||
protected Collection $schedule_items;
|
||||
|
||||
/** @var Collection<int, StationStreamerBroadcast> */
|
||||
#[
|
||||
ORM\OneToMany(targetEntity: StationStreamerBroadcast::class, mappedBy: 'streamer'),
|
||||
DeepNormalize(true)
|
||||
]
|
||||
protected Collection $broadcasts;
|
||||
|
||||
public function __construct(Station $station)
|
||||
{
|
||||
$this->station = $station;
|
||||
|
||||
$this->schedule_items = new ArrayCollection();
|
||||
$this->broadcasts = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getStation(): Station
|
||||
|
|
|
@ -98,9 +98,13 @@ class StorageLocation implements Stringable, IdentifiableEntityInterface
|
|||
protected string|int|null $storageUsed = null;
|
||||
|
||||
/** @var Collection<int, StationMedia> */
|
||||
#[ORM\OneToMany(mappedBy: 'storage_location', targetEntity: StationMedia::class)]
|
||||
#[ORM\OneToMany(targetEntity: StationMedia::class, mappedBy: 'storage_location')]
|
||||
protected Collection $media;
|
||||
|
||||
/** @var Collection<int, UnprocessableMedia> */
|
||||
#[ORM\OneToMany(targetEntity: UnprocessableMedia::class, mappedBy: 'storage_location')]
|
||||
protected Collection $unprocessable_media;
|
||||
|
||||
public function __construct(
|
||||
StorageLocationTypes $type,
|
||||
StorageLocationAdapters $adapter
|
||||
|
@ -109,6 +113,7 @@ class StorageLocation implements Stringable, IdentifiableEntityInterface
|
|||
$this->adapter = $adapter;
|
||||
|
||||
$this->media = new ArrayCollection();
|
||||
$this->unprocessable_media = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getType(): StorageLocationTypes
|
||||
|
|
|
@ -20,7 +20,7 @@ class UnprocessableMedia implements ProcessableMediaInterface, PathAwareInterfac
|
|||
|
||||
public const REPROCESS_THRESHOLD_MINIMUM = 604800; // One week
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'media')]
|
||||
#[ORM\ManyToOne(inversedBy: 'unprocessable_media')]
|
||||
#[ORM\JoinColumn(name: 'storage_location_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
protected StorageLocation $storage_location;
|
||||
|
||||
|
|
|
@ -119,7 +119,7 @@ class User implements Stringable, IdentifiableEntityInterface
|
|||
|
||||
/** @var Collection<int, ApiKey> */
|
||||
#[
|
||||
ORM\OneToMany(mappedBy: 'user', targetEntity: ApiKey::class),
|
||||
ORM\OneToMany(targetEntity: ApiKey::class, mappedBy: 'user'),
|
||||
Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL]),
|
||||
DeepNormalize(true)
|
||||
]
|
||||
|
@ -127,12 +127,20 @@ class User implements Stringable, IdentifiableEntityInterface
|
|||
|
||||
/** @var Collection<int, UserPasskey> */
|
||||
#[
|
||||
ORM\OneToMany(mappedBy: 'user', targetEntity: UserPasskey::class),
|
||||
ORM\OneToMany(targetEntity: UserPasskey::class, mappedBy: 'user'),
|
||||
Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL]),
|
||||
DeepNormalize(true)
|
||||
]
|
||||
protected Collection $passkeys;
|
||||
|
||||
/** @var Collection<int, UserLoginToken> */
|
||||
#[
|
||||
ORM\OneToMany(targetEntity: UserLoginToken::class, mappedBy: 'user'),
|
||||
Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL]),
|
||||
DeepNormalize(true)
|
||||
]
|
||||
protected Collection $login_tokens;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->created_at = time();
|
||||
|
@ -140,6 +148,7 @@ class User implements Stringable, IdentifiableEntityInterface
|
|||
|
||||
$this->roles = new ArrayCollection();
|
||||
$this->api_keys = new ArrayCollection();
|
||||
$this->login_tokens = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
|
|
|
@ -15,7 +15,7 @@ class UserLoginToken
|
|||
{
|
||||
use Traits\HasSplitTokenFields;
|
||||
|
||||
#[ORM\ManyToOne(fetch: 'EAGER', inversedBy: 'api_keys')]
|
||||
#[ORM\ManyToOne(fetch: 'EAGER', inversedBy: 'login_tokens')]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
protected User $user;
|
||||
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Sync\Task;
|
||||
|
||||
use App\Entity\Enums\PodcastSources;
|
||||
use App\Entity\Podcast;
|
||||
use App\Entity\PodcastEpisode;
|
||||
use App\Entity\Repository\PodcastEpisodeRepository;
|
||||
use App\Entity\Station;
|
||||
use App\Entity\StationMedia;
|
||||
use App\Flysystem\StationFilesystems;
|
||||
|
||||
final class CheckPodcastPlaylistsTask extends AbstractTask
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StationFilesystems $stationFilesystems,
|
||||
private readonly PodcastEpisodeRepository $podcastEpisodeRepo
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSchedulePattern(): string
|
||||
{
|
||||
return '*/10 * * * *';
|
||||
}
|
||||
|
||||
public function run(bool $force = false): void
|
||||
{
|
||||
foreach ($this->iterateStations() as $station) {
|
||||
$this->syncPodcastPlaylists($station);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncPodcastPlaylists(Station $station): void
|
||||
{
|
||||
$this->logger->info(
|
||||
'Processing playlist-based podcasts for station...',
|
||||
[
|
||||
'station' => $station->getName(),
|
||||
]
|
||||
);
|
||||
|
||||
$fsMedia = $this->stationFilesystems->getMediaFilesystem($station);
|
||||
$fsPodcasts = $this->stationFilesystems->getPodcastsFilesystem($station);
|
||||
|
||||
$podcasts = $this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT p, sp
|
||||
FROM App\Entity\Podcast p
|
||||
JOIN p.playlist sp
|
||||
WHERE p.source = :source
|
||||
DQL
|
||||
)->setParameter('source', PodcastSources::Playlist->value)
|
||||
->execute();
|
||||
|
||||
$mediaInPlaylistQuery = $this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT spm.media_id
|
||||
FROM App\Entity\StationPlaylistMedia spm
|
||||
WHERE spm.playlist = :playlist
|
||||
DQL
|
||||
);
|
||||
|
||||
$mediaInPodcastQuery = $this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT pe.id, pe.playlist_media_id
|
||||
FROM App\Entity\PodcastEpisode pe
|
||||
WHERE pe.podcast = :podcast
|
||||
DQL
|
||||
);
|
||||
|
||||
$stats = [
|
||||
'added' => 0,
|
||||
'removed' => 0,
|
||||
'unchanged' => 0,
|
||||
];
|
||||
|
||||
/** @var Podcast $podcast */
|
||||
foreach ($podcasts as $podcast) {
|
||||
$playlist = $podcast->getPlaylist();
|
||||
|
||||
$mediaInPlaylist = array_column(
|
||||
$mediaInPlaylistQuery->setParameter('playlist', $playlist)->getArrayResult(),
|
||||
'media_id',
|
||||
'media_id'
|
||||
);
|
||||
|
||||
$mediaInPodcast = array_column(
|
||||
$mediaInPodcastQuery->setParameter('podcast', $podcast)->getArrayResult(),
|
||||
'id',
|
||||
'playlist_media_id'
|
||||
);
|
||||
|
||||
$mediaToAdd = [];
|
||||
foreach ($mediaInPlaylist as $mediaId) {
|
||||
if (isset($mediaInPodcast[$mediaId])) {
|
||||
$stats['unchanged']++;
|
||||
unset($mediaInPodcast[$mediaId]);
|
||||
} else {
|
||||
$mediaToAdd[] = $mediaId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($mediaToAdd as $mediaId) {
|
||||
$media = $this->em->find(StationMedia::class, $mediaId);
|
||||
|
||||
if ($media instanceof StationMedia) {
|
||||
// Create new podcast episode.
|
||||
$podcastEpisode = new PodcastEpisode($podcast);
|
||||
$podcastEpisode->setPlaylistMedia($media);
|
||||
|
||||
$podcastEpisode->setExplicit(false);
|
||||
|
||||
$podcastEpisode->setTitle($media->getTitle() ?? 'Untitled Episode');
|
||||
$podcastEpisode->setDescription(
|
||||
implode("\n", array_filter([
|
||||
$media->getArtist(),
|
||||
$media->getAlbum(),
|
||||
$media->getLyrics(),
|
||||
]))
|
||||
);
|
||||
|
||||
if (!$podcast->playlistAutoPublish()) {
|
||||
// Set a date in the future to unpublish the episode.
|
||||
$podcastEpisode->setPublishAt(
|
||||
strtotime('+10 years')
|
||||
);
|
||||
} else {
|
||||
$podcastEpisode->setPublishAt($media->getMtime());
|
||||
}
|
||||
|
||||
$this->em->persist($podcastEpisode);
|
||||
$this->em->flush();
|
||||
|
||||
$artPath = StationMedia::getArtPath($media->getUniqueId());
|
||||
if ($fsMedia->fileExists($artPath)) {
|
||||
$art = $fsMedia->read($artPath);
|
||||
$this->podcastEpisodeRepo->writeEpisodeArt($podcastEpisode, $art);
|
||||
}
|
||||
|
||||
$stats['added']++;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove remaining media that doesn't match.
|
||||
foreach ($mediaInPodcast as $episodeId) {
|
||||
$episode = $this->em->find(PodcastEpisode::class, $episodeId);
|
||||
|
||||
if ($episode instanceof PodcastEpisode) {
|
||||
$this->podcastEpisodeRepo->delete($episode, $fsPodcasts);
|
||||
}
|
||||
|
||||
$stats['removed']++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug(
|
||||
'Playlist-based podcasts for station processed.',
|
||||
[
|
||||
'station' => $station->getName(),
|
||||
'stats' => $stats,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue