Compare commits
10 Commits
8407ee1a8f
...
4ed0b21370
Author | SHA1 | Date |
---|---|---|
Optischa | 4ed0b21370 | |
Buster Neece | a1cd71922f | |
Buster Neece | 1a68f34452 | |
Buster Neece | b1662a4757 | |
Buster Neece | da51ec823c | |
Buster Neece | 3078f4f686 | |
Buster Neece | 66df8f7850 | |
Niklas H | 263c4d82b5 | |
Optischa | 259dc9733f | |
Optischa | 40cedfb1a6 |
|
@ -8,6 +8,7 @@
|
|||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.1.2",
|
||||
"@flowjs/flow.js": "^2.14.1",
|
||||
"@fullcalendar/bootstrap5": "^6.1.8",
|
||||
|
@ -231,6 +232,22 @@
|
|||
"@lezer/css": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
|
||||
"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz",
|
||||
|
@ -932,6 +949,16 @@
|
|||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.9.tgz",
|
||||
"integrity": "sha512-MXxeCMPyrcemSLGaTQEZx0dBUH0i+RPl8RN5GwMAzo53nTsd/Unc/t5ZxACeQoyPUM5/GkPLRUs2WliOImzkRA==",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.14.tgz",
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.1.2",
|
||||
"@flowjs/flow.js": "^2.14.1",
|
||||
"@fullcalendar/bootstrap5": "^6.1.8",
|
||||
|
|
|
@ -13,6 +13,7 @@ import {useVModel} from "@vueuse/core";
|
|||
import {computed} from "vue";
|
||||
import {css} from "@codemirror/lang-css";
|
||||
import {javascript} from "@codemirror/lang-javascript";
|
||||
import {html} from "@codemirror/lang-html";
|
||||
import {liquidsoap} from "codemirror-lang-liquidsoap";
|
||||
import useTheme from "~/functions/theme";
|
||||
|
||||
|
@ -31,6 +32,8 @@ const lang = computed(() => {
|
|||
return css();
|
||||
case 'javascript':
|
||||
return javascript();
|
||||
case 'html':
|
||||
return html();
|
||||
case 'liquidsoap':
|
||||
return liquidsoap();
|
||||
default:
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
<h5 class="m-0">
|
||||
{{ item.title }}
|
||||
</h5>
|
||||
<div v-if="item.is_published">
|
||||
<div v-if="item.is_published && item.is_enabled">
|
||||
<a
|
||||
:href="item.links.public_episodes"
|
||||
target="_blank"
|
||||
|
@ -59,6 +59,12 @@
|
|||
>
|
||||
{{ $gettext('Unpublished') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!item.is_enabled"
|
||||
class="badge text-bg-danger"
|
||||
>
|
||||
{{ $gettext('Disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(actions)="{item}">
|
||||
|
|
|
@ -10,13 +10,13 @@
|
|||
>
|
||||
<tabs>
|
||||
<podcast-form-basic-info
|
||||
:form="form"
|
||||
v-model:form="form"
|
||||
:categories-options="categoriesOptions"
|
||||
:language-options="languageOptions"
|
||||
/>
|
||||
|
||||
<podcast-form-source
|
||||
:form="form"
|
||||
v-model:form="form"
|
||||
/>
|
||||
|
||||
<podcast-common-artwork
|
||||
|
@ -24,6 +24,10 @@
|
|||
:artwork-src="record.links.art"
|
||||
:new-art-url="newArtUrl"
|
||||
/>
|
||||
|
||||
<podcast-form-branding
|
||||
v-model:form="form"
|
||||
/>
|
||||
</tabs>
|
||||
</modal-form>
|
||||
</template>
|
||||
|
@ -31,6 +35,7 @@
|
|||
<script setup lang="ts">
|
||||
import PodcastFormBasicInfo from './PodcastForm/BasicInfo.vue';
|
||||
import PodcastFormSource from './PodcastForm/Source.vue';
|
||||
import PodcastFormBranding from './PodcastForm/Branding.vue';
|
||||
import PodcastCommonArtwork from './Common/Artwork.vue';
|
||||
import mergeExisting from "~/functions/mergeExisting";
|
||||
import {baseEditModalProps, ModalFormTemplateRef, useBaseEditModal} from "~/functions/useBaseEditModal";
|
||||
|
@ -99,8 +104,11 @@ const {
|
|||
(row) => row.category
|
||||
);
|
||||
|
||||
|
||||
record.value = data;
|
||||
formRef.value = mergeExisting(formRef.value, data);
|
||||
|
||||
console.log(formRef.value);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -63,6 +63,14 @@
|
|||
:label="$gettext('Categories')"
|
||||
:description="$gettext('Select the category/categories that best reflects the content of your podcast.')"
|
||||
/>
|
||||
|
||||
<form-group-checkbox
|
||||
id="edit_form_is_enabled"
|
||||
class="col-md-12"
|
||||
:field="v$.is_enabled"
|
||||
:label="$gettext('Enable on Public Pages')"
|
||||
:description="$gettext('If disabled, the station will not be visible on public-facing pages or APIs.')"
|
||||
/>
|
||||
</div>
|
||||
</tab>
|
||||
</template>
|
||||
|
@ -74,6 +82,7 @@ import {useVModel} from "@vueuse/core";
|
|||
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||
import {required} from "@vuelidate/validators";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
||||
|
||||
const props = defineProps({
|
||||
form: {
|
||||
|
@ -102,6 +111,7 @@ const {v$, tabClass} = useVuelidateOnFormTab(
|
|||
author: {},
|
||||
email: {},
|
||||
categories: {required},
|
||||
is_enabled: {},
|
||||
},
|
||||
form,
|
||||
{
|
||||
|
@ -112,6 +122,7 @@ const {v$, tabClass} = useVuelidateOnFormTab(
|
|||
author: '',
|
||||
email: '',
|
||||
categories: [],
|
||||
is_enabled: true
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<tab
|
||||
:label="$gettext('Branding')"
|
||||
:item-header-class="tabClass"
|
||||
>
|
||||
<div class="row g-3">
|
||||
<form-group-field
|
||||
id="edit_form_public_custom_html"
|
||||
class="col-md-12"
|
||||
:field="v$.branding_config.public_custom_html"
|
||||
:label="$gettext('Custom HTML for Public Pages')"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<codemirror-textarea
|
||||
:id="slotProps.id"
|
||||
v-model="slotProps.field.$model"
|
||||
mode="html"
|
||||
/>
|
||||
</template>
|
||||
</form-group-field>
|
||||
</div>
|
||||
</tab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useVModel} from "@vueuse/core";
|
||||
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import CodemirrorTextarea from "~/components/Common/CodemirrorTextarea.vue";
|
||||
import FormGroupField from "~/components/Form/FormGroupField.vue";
|
||||
|
||||
const props = defineProps({
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:form']);
|
||||
const form = useVModel(props, 'form', emit);
|
||||
|
||||
const {v$, tabClass} = useVuelidateOnFormTab(
|
||||
{
|
||||
branding_config: {
|
||||
public_custom_html: {}
|
||||
},
|
||||
},
|
||||
form,
|
||||
{
|
||||
branding_config: {
|
||||
public_custom_html: ''
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
|
@ -50,6 +50,7 @@ import {getTriggers, WebhookType} from "~/entities/Webhooks";
|
|||
import Tabs from "~/components/Common/Tabs.vue";
|
||||
import RadioDe from "~/components/Stations/Webhooks/Form/RadioDe.vue";
|
||||
import GetMeRadio from "~/components/Stations/Webhooks/Form/GetMeRadio.vue";
|
||||
import RadioReg from "~/components/Stations/Webhooks/Form/RadioReg.vue";
|
||||
|
||||
const props = defineProps({
|
||||
...baseEditModalProps,
|
||||
|
@ -80,6 +81,7 @@ const webhookComponents = {
|
|||
[WebhookType.Email]: Email,
|
||||
[WebhookType.TuneIn]: Tunein,
|
||||
[WebhookType.RadioDe]: RadioDe,
|
||||
[WebhookType.RadioReg]: RadioReg,
|
||||
[WebhookType.GetMeRadio]: GetMeRadio,
|
||||
[WebhookType.Discord]: Discord,
|
||||
[WebhookType.Telegram]: Telegram,
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<tab
|
||||
:label="title"
|
||||
:item-header-class="tabClass"
|
||||
>
|
||||
<div class="row g-3">
|
||||
<form-group-field
|
||||
id="form_config_webhookurl"
|
||||
class="col-md-12"
|
||||
:field="v$.config.broadcastsubdomain"
|
||||
:label="$gettext('RadioReg Webhook URL')"
|
||||
/>
|
||||
|
||||
<form-group-field
|
||||
id="form_config_apikey"
|
||||
class="col-md-6"
|
||||
:field="v$.config.apikey"
|
||||
:label="$gettext('RadioRed Organization API Key')"
|
||||
/>
|
||||
</div>
|
||||
</tab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FormGroupField from "~/components/Form/FormGroupField.vue";
|
||||
import {useVModel} from "@vueuse/core";
|
||||
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||
import {required} from "@vuelidate/validators";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:form']);
|
||||
const form = useVModel(props, 'form', emit);
|
||||
|
||||
const {v$, tabClass} = useVuelidateOnFormTab(
|
||||
{
|
||||
config: {
|
||||
webhookurl: {required},
|
||||
apikey: {required}
|
||||
}
|
||||
},
|
||||
form,
|
||||
{
|
||||
config: {
|
||||
webhookurl: '',
|
||||
apikey: ''
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
|
@ -26,6 +26,7 @@
|
|||
:types="buildTypeInfo([
|
||||
WebhookType.TuneIn,
|
||||
WebhookType.RadioDe,
|
||||
WebhookType.RadioReg,
|
||||
WebhookType.GetMeRadio
|
||||
])"
|
||||
@select="selectType"
|
||||
|
|
|
@ -75,6 +75,7 @@ export enum WebhookType {
|
|||
Email = 'email',
|
||||
TuneIn = 'tunein',
|
||||
RadioDe = 'radiode',
|
||||
RadioReg = 'radioreg',
|
||||
GetMeRadio = 'getmeradio',
|
||||
Discord = 'discord',
|
||||
Telegram = 'telegram',
|
||||
|
@ -103,6 +104,10 @@ export function useTypeDetails() {
|
|||
title: $gettext('Radio.de'),
|
||||
description: $gettext('Send song metadata changes to %{service}.', {service: 'Radio.de'})
|
||||
},
|
||||
[WebhookType.RadioReg]: {
|
||||
title: $gettext('RadioReg.net'),
|
||||
description: $gettext('Send song metadata changes to %{service}.', {service: 'RadioReg'})
|
||||
},
|
||||
[WebhookType.GetMeRadio]: {
|
||||
title: $gettext('GetMeRadio'),
|
||||
description: $gettext('Send song metadata changes to %{service}', {service: 'GetMeRadio'})
|
||||
|
@ -134,6 +139,7 @@ export function getTriggers(type: WebhookType) {
|
|||
switch(type) {
|
||||
case WebhookType.TuneIn:
|
||||
case WebhookType.RadioDe:
|
||||
case WebhookType.RadioReg:
|
||||
case WebhookType.GetMeRadio:
|
||||
case WebhookType.GoogleAnalyticsV4:
|
||||
case WebhookType.MatomoAnalytics:
|
||||
|
|
|
@ -35,6 +35,7 @@ final class ListPodcastsAction implements SingleActionInterface
|
|||
->from(Podcast::class, 'p')
|
||||
->leftJoin('p.categories', 'pc')
|
||||
->where('p.storage_location = :storageLocation')
|
||||
->andWhere('p.is_enabled = 1')
|
||||
->setParameter('storageLocation', $station->getPodcastsStorageLocation())
|
||||
->andWhere('p.id IN (:podcastIds)')
|
||||
->setParameter('podcastIds', $this->podcastRepo->getPodcastIdsWithPublishedEpisodes($station))
|
||||
|
|
|
@ -29,9 +29,13 @@ abstract class AbstractStationConfiguration implements JsonSerializable
|
|||
return $return;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
public function jsonSerialize(): array|object
|
||||
{
|
||||
return $this->toArray();
|
||||
$result = $this->toArray();
|
||||
|
||||
return (0 !== count($result))
|
||||
? $result
|
||||
: (object)[];
|
||||
}
|
||||
|
||||
protected function get(string $key, mixed $default = null): mixed
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace App\Entity\Api;
|
||||
|
||||
use App\Entity\Api\Traits\HasLinks;
|
||||
use App\Entity\PodcastBrandingConfiguration;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
|
@ -42,12 +43,15 @@ final class Podcast
|
|||
#[OA\Property]
|
||||
public string $description_short;
|
||||
|
||||
#[OA\Property]
|
||||
public bool $is_enabled = true;
|
||||
|
||||
#[OA\Property(
|
||||
description: "An array containing podcast-specific branding configuration",
|
||||
type: "array",
|
||||
items: new OA\Items()
|
||||
)]
|
||||
public array $branding_config;
|
||||
public PodcastBrandingConfiguration $branding_config;
|
||||
|
||||
#[OA\Property]
|
||||
public string $language;
|
||||
|
|
|
@ -48,7 +48,9 @@ final class PodcastApiGenerator
|
|||
$return->description = $record->getDescription();
|
||||
$return->description_short = Strings::truncateText($return->description, 200);
|
||||
|
||||
$return->branding_config = $record->getBrandingConfig()->toArray();
|
||||
$return->is_enabled = $record->isEnabled();
|
||||
|
||||
$return->branding_config = $record->getBrandingConfig();
|
||||
|
||||
$return->language = $record->getLanguage();
|
||||
try {
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
final class Version20240425151151 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add is_enabled flag for podcasts.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE podcast ADD is_enabled TINYINT(1) NOT NULL AFTER description');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE podcast
|
||||
SET is_enabled=1
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE podcast DROP is_enabled');
|
||||
}
|
||||
}
|
|
@ -52,6 +52,9 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
|
|||
#[Assert\NotBlank]
|
||||
protected string $description;
|
||||
|
||||
#[ORM\Column]
|
||||
protected bool $is_enabled = true;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
protected ?array $branding_config = null;
|
||||
|
||||
|
@ -150,6 +153,16 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->is_enabled;
|
||||
}
|
||||
|
||||
public function setIsEnabled(bool $is_enabled): void
|
||||
{
|
||||
$this->is_enabled = $is_enabled;
|
||||
}
|
||||
|
||||
public function getBrandingConfig(): PodcastBrandingConfiguration
|
||||
{
|
||||
return new PodcastBrandingConfiguration((array)$this->branding_config);
|
||||
|
|
|
@ -4,6 +4,19 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Utilities\Types;
|
||||
|
||||
class PodcastBrandingConfiguration extends AbstractStationConfiguration
|
||||
{
|
||||
public const PUBLIC_CUSTOM_HTML = 'public_custom_html';
|
||||
|
||||
public function getPublicCustomHtml(): ?string
|
||||
{
|
||||
return Types::stringOrNull($this->get(self::PUBLIC_CUSTOM_HTML), true);
|
||||
}
|
||||
|
||||
public function setPublicCustomHtml(?string $html): void
|
||||
{
|
||||
$this->set(self::PUBLIC_CUSTOM_HTML, $html);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,8 @@ final class RequirePublishedPodcastEpisodeMiddleware extends AbstractMiddleware
|
|||
$publishedPodcastIds = $this->podcastRepository->getPodcastIdsWithPublishedEpisodes($station);
|
||||
|
||||
$podcast = $request->getPodcast();
|
||||
if (!in_array($podcast->getIdRequired(), $publishedPodcastIds, true)) {
|
||||
|
||||
if (!$podcast->isEnabled() || !in_array($podcast->getIdRequired(), $publishedPodcastIds, true)) {
|
||||
throw NotFoundException::podcast();
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Webhook\Connector;
|
||||
|
||||
use App\Entity\Api\NowPlaying\NowPlaying;
|
||||
use App\Entity\Station;
|
||||
use App\Entity\StationWebhook;
|
||||
use App\Webhook\Enums\WebhookTriggers;
|
||||
|
||||
final class RadioReg extends AbstractConnector
|
||||
{
|
||||
protected function webhookShouldTrigger(StationWebhook $webhook, array $triggers = []): bool
|
||||
{
|
||||
return in_array(WebhookTriggers::SongChanged->value, $triggers, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @optischa
|
||||
*/
|
||||
public function dispatch(
|
||||
Station $station,
|
||||
StationWebhook $webhook,
|
||||
NowPlaying $np,
|
||||
array $triggers
|
||||
): void {
|
||||
$config = $webhook->getConfig();
|
||||
|
||||
if (
|
||||
empty($config['apikey']) || empty($config['webhookurl'])
|
||||
) {
|
||||
throw $this->incompleteConfigException($webhook);
|
||||
}
|
||||
|
||||
$this->logger->debug('Dispatching RadioReg API call...');
|
||||
|
||||
$messageBody = [
|
||||
'title' => $np->now_playing?->song?->title,
|
||||
'artist' => $np->now_playing?->song?->artist,
|
||||
];
|
||||
|
||||
$response = $this->httpClient->post(
|
||||
$config['webhookurl'],
|
||||
[
|
||||
'query' => $messageBody,
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'X-API-KEY' => $config['apikey'],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->logHttpResponse($webhook, $response, $messageBody);
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ use App\Webhook\Connector\GoogleAnalyticsV4;
|
|||
use App\Webhook\Connector\Mastodon;
|
||||
use App\Webhook\Connector\MatomoAnalytics;
|
||||
use App\Webhook\Connector\RadioDe;
|
||||
use App\Webhook\Connector\RadioReg;
|
||||
use App\Webhook\Connector\Telegram;
|
||||
use App\Webhook\Connector\TuneIn;
|
||||
|
||||
|
@ -22,6 +23,7 @@ enum WebhookTypes: string
|
|||
|
||||
case TuneIn = 'tunein';
|
||||
case RadioDe = 'radiode';
|
||||
case RadioReg = 'radioreg';
|
||||
case GetMeRadio = 'getmeradio';
|
||||
|
||||
case Discord = 'discord';
|
||||
|
@ -44,6 +46,7 @@ enum WebhookTypes: string
|
|||
self::Generic => Generic::class,
|
||||
self::Email => Email::class,
|
||||
self::TuneIn => TuneIn::class,
|
||||
self::RadioReg => RadioReg::class,
|
||||
self::RadioDe => RadioDe::class,
|
||||
self::GetMeRadio => GetMeRadio::class,
|
||||
self::Discord => Discord::class,
|
||||
|
|
Loading…
Reference in New Issue