Compare commits

...

14 Commits

Author SHA1 Message Date
Optischa ea5bebeec5
Merge 263c4d82b5 into 6feae35d81 2024-04-21 22:43:44 +02:00
Buster Neece 6feae35d81
Fix links in Podcast RSS feeds; add "podcast:guid" calculated from global podcast namespace. 2024-04-21 10:23:53 -05:00
Buster Neece f12b3c0da2
Finish removing MarcW RSS writer components. 2024-04-21 09:52:28 -05:00
Buster Neece d03dc1f277
Update the Podcast RSS feed to write raw XML instead of using an RSS library; add iTunes season/episode. 2024-04-21 09:46:47 -05:00
Buster Neece ba4a71cd98
Update XML writer to support writing root element text and attributes (the reverse of what Reader does). 2024-04-21 09:46:20 -05:00
Buster Neece 820cc7ad03
Make XML writer support excluding opening tag in the writing process. 2024-04-21 07:09:28 -05:00
Buster Neece d438be0a72
Add admin panel UI for season/episode numbers. 2024-04-21 05:19:37 -05:00
Buster Neece 1871b7a0cd
Code style cleanup fixes. 2024-04-21 05:01:06 -05:00
Buster Neece 17e83547f7
Update API spec and API interfaces with new fields. 2024-04-21 04:55:04 -05:00
Buster Neece c881a28be4
Update Podcast to add branding config; update PodcastEpisode to add season and episode numbers. 2024-04-21 04:54:21 -05:00
Buster Neece c84522105d
Slightly modify DB migration template. 2024-04-21 04:47:32 -05:00
Niklas H 263c4d82b5
Merge branch 'main' into main 2024-04-16 17:28:34 +02:00
Optischa 259dc9733f
Merge branch 'AzuraCast:main' into main 2024-04-08 23:02:41 +02:00
Optischa 40cedfb1a6 Add RadioReg Api for send metadata 2024-04-05 01:13:10 +02:00
25 changed files with 473 additions and 256 deletions

View File

@ -52,7 +52,6 @@
"league/oauth2-client": "^2.6",
"league/plates": "^3.1",
"lstrojny/fxmlrpc": "dev-master",
"marcw/rss-writer": "^0.4.0",
"matomo/device-detector": "^6",
"mezzio/mezzio-session": "^1.3",
"mezzio/mezzio-session-cache": "^1.7",

60
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5bca0c641ba21645d05ab830394898ae",
"content-hash": "77d8e4e6837ec47f02ea21d3f3cb767e",
"packages": [
{
"name": "aws/aws-crt-php",
@ -3505,64 +3505,6 @@
},
"time": "2023-08-22T06:06:43+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": "6.3.0",

View File

@ -205,6 +205,20 @@ const fields: DataTableField[] = [
sortable: true,
selectable: true
},
{
key: 'season_number',
label: $gettext('Season Number'),
visible: false,
sortable: true,
selectable: true
},
{
key: 'episode_number',
label: $gettext('Episode Number'),
visible: false,
sortable: true,
selectable: true
},
{
key: 'actions',
label: $gettext('Actions'),

View File

@ -53,6 +53,28 @@
:label="$gettext('Contains explicit content')"
:description="$gettext('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 aren\'t available in some Apple Podcasts territories.')"
/>
<form-group-field
id="form_edit_season_number"
class="col-md-6"
:field="v$.season_number"
input-type="number"
:input-attrs="{ step: '1' }"
:label="$gettext('Season Number')"
:description="$gettext('Optionally list this episode as part of a season in some podcast aggregators.')"
clearable
/>
<form-group-field
id="form_edit_episode_number"
class="col-md-6"
:field="v$.episode_number"
input-type="number"
:input-attrs="{ step: '1' }"
:label="$gettext('Episode Number')"
:description="$gettext('Optionally set a specific episode number in some podcast aggregators.')"
clearable
/>
</div>
</tab>
</template>
@ -83,6 +105,8 @@ const {v$, tabClass} = useVuelidateOnFormTab(
publish_date: {},
publish_time: {},
explicit: {},
season_number: {},
episode_number: {}
},
form,
{
@ -91,7 +115,9 @@ const {v$, tabClass} = useVuelidateOnFormTab(
description: '',
publish_date: '',
publish_time: '',
explicit: false
explicit: false,
season_number: null,
episode_number: null
}
);
</script>

View File

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

View File

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

View File

@ -26,6 +26,7 @@
:types="buildTypeInfo([
WebhookType.TuneIn,
WebhookType.RadioDe,
WebhookType.RadioReg,
WebhookType.GetMeRadio
])"
@select="selectType"

View File

@ -611,6 +611,8 @@ export type ApiPodcast = HasLinks & {
link?: string | null;
description?: string;
description_short?: string;
/** An array containing podcast-specific branding configuration */
branding_config?: any[];
language?: string;
language_name?: string;
author?: string;
@ -633,12 +635,15 @@ export interface ApiPodcastCategory {
export type ApiPodcastEpisode = HasLinks & {
id?: string;
title?: string;
link?: string | null;
description?: string;
description_short?: string;
explicit?: boolean;
season_number?: number | null;
episode_number?: number | null;
created_at?: number;
publish_at?: number;
is_published?: boolean;
publish_at?: number | null;
has_media?: boolean;
playlist_media_id?: string | null;
playlist_media?: ApiSong | null;
@ -1501,6 +1506,8 @@ export type StationPlaylist = HasAutoIncrementId & {
avoid_duplicates?: boolean;
/** StationSchedule> */
schedule_items?: any[];
/** Podcast> */
podcasts?: any[];
};
export type StationSchedule = HasAutoIncrementId & {

View File

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

View File

@ -7,36 +7,20 @@ namespace App\Controller\Frontend\PublicPages;
use App\Controller\SingleActionInterface;
use App\Entity\ApiGenerator\PodcastApiGenerator;
use App\Entity\ApiGenerator\PodcastEpisodeApiGenerator;
use App\Entity\Podcast;
use App\Entity\PodcastCategory;
use App\Entity\PodcastEpisode;
use App\Exception\NotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Rss\PodcastNamespaceWriter;
use DateTime;
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\Itunes\ItunesChannel;
use MarcW\RssWriter\Extension\Itunes\ItunesItem;
use MarcW\RssWriter\Extension\Itunes\ItunesOwner;
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 App\Xml\Writer;
use Carbon\CarbonImmutable;
use Psr\Http\Message\ResponseInterface;
use Ramsey\Uuid\Uuid;
final class PodcastFeedAction implements SingleActionInterface
{
public const string PODCAST_NAMESPACE = 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6';
public function __construct(
private readonly PodcastApiGenerator $podcastApiGenerator,
private readonly PodcastEpisodeApiGenerator $episodeApiGenerator
@ -55,40 +39,78 @@ final class PodcastFeedAction implements SingleActionInterface
$podcast = $request->getPodcast();
$channel = new RssChannel();
$channel->setTtl(5);
$channel->setLastBuildDate(new DateTime());
// Fetch podcast API feed.
$podcastApi = $this->podcastApiGenerator->__invoke($podcast, $request);
$channel->setTitle($podcastApi->title);
$channel->setDescription($podcastApi->description);
$channel->setLink($podcastApi->link ?? $podcastApi->links['self']);
$channel->setLanguage($podcastApi->language);
$now = CarbonImmutable::now($station->getTimezoneObject());
$channel->setCategories(
$podcast->getCategories()->map(
$rss = [
'@xmlns:itunes' => 'http://www.itunes.com/dtds/podcast-1.0.dtd',
'@xmlns:sy' => 'http://purl.org/rss/1.0/modules/syndication/',
'@xmlns:slash' => 'http://purl.org/rss/1.0/modules/slash/',
'@xmlns:atom' => 'http://www.w3.org/2005/Atom',
'@xmlns:podcast' => 'https://podcastindex.org/namespace/1.0',
'@version' => '2.0',
];
$channel = [
'title' => $podcastApi->title,
'link' => $podcastApi->link ?? $podcastApi->links['public_episodes'],
'description' => $podcastApi->description,
'language' => $podcastApi->language,
'lastBuildDate' => $now->toRssString(),
'category' => $podcast->getCategories()->map(
function (PodcastCategory $podcastCategory) {
$rssCategory = new RssCategory();
if (null === $podcastCategory->getSubTitle()) {
$rssCategory->setTitle($podcastCategory->getTitle());
} else {
$rssCategory->setTitle($podcastCategory->getSubTitle());
}
return $rssCategory;
return (null === $podcastCategory->getSubTitle())
? $podcastCategory->getTitle()
: $podcastCategory->getSubTitle();
}
)->getValues()
);
)->getValues(),
'ttl' => 5,
'image' => [
'url' => $podcastApi->art,
'title' => $podcastApi->title,
],
'itunes:author' => $podcastApi->author,
'itunes:owner' => [],
'itunes:image' => [
'@href' => $podcastApi->art,
],
'itunes:explicit' => 'false',
'itunes:category' => $podcast->getCategories()->map(
function (PodcastCategory $podcastCategory) {
return (null === $podcastCategory->getSubTitle())
? [
'@text' => $podcastCategory->getTitle(),
] : [
'@text' => $podcastCategory->getTitle(),
'itunes:category' => [
'@text' => $podcastCategory->getSubTitle(),
],
];
}
)->getValues(),
'atom:link' => [
'@rel' => 'self',
'@type' => 'application/rss+xml',
'@href' => (string)$request->getUri(),
],
'podcast:guid' => $this->buildPodcastGuid($podcastApi->links['public_feed']),
'item' => [],
];
$rssImage = new RssImage();
$rssImage->setTitle($podcastApi->title);
$rssImage->setUrl($podcastApi->art);
if (null !== $podcastApi->link) {
$rssImage->setLink($podcastApi->link);
$channel['image']['link'] = $podcastApi->link;
}
$channel->setImage($rssImage);
if (empty($podcastApi->author) && empty($podcastApi->email)) {
unset($channel['itunes:owner']);
} else {
$channel['itunes:owner'] = [
'itunes:name' => $podcastApi->author,
'itunes:email' => $podcastApi->email,
];
}
// Iterate through episodes.
$hasPublishedEpisode = false;
@ -105,53 +127,21 @@ final class PodcastFeedAction implements SingleActionInterface
$hasExplicitEpisode = true;
}
$channel->addItem($this->buildItemForEpisode($episode, $request));
$channel['item'][] = $this->buildItemForEpisode($episode, $request);
}
if (!$hasPublishedEpisode) {
throw NotFoundException::podcast();
}
$itunesChannel = new ItunesChannel();
$itunesChannel->setExplicit($hasExplicitEpisode);
$itunesChannel->setImage($rssImage->getUrl());
$itunesChannel->setCategories(
$podcast->getCategories()->map(
function (PodcastCategory $podcastCategory) {
return (null === $podcastCategory->getSubTitle())
? $podcastCategory->getTitle()
: [
$podcastCategory->getTitle(),
$podcastCategory->getSubTitle(),
];
}
)->getValues()
);
if ($hasExplicitEpisode) {
$channel['itunes:explicit'] = 'true';
}
$itunesChannel->setOwner($this->buildItunesOwner($podcast));
$itunesChannel->setAuthor($podcast->getAuthor());
$channel->addExtension($itunesChannel);
$channel->addExtension(new Sy());
$channel->addExtension(new Slash());
$channel->addExtension(
(new AtomLink())
->setRel('self')
->setHref((string)$request->getUri())
->setType('application/rss+xml')
);
$rssWriter = new RssWriter(null, [
new CoreWriter(),
new ItunesWriter(),
new SyWriter(),
new SlashWriter(),
new AtomWriter(),
new PodcastNamespaceWriter(),
], true);
$rss['channel'] = $channel;
$response->getBody()->write(
$rssWriter->writeChannel($channel)
Writer::toString($rss, 'rss')
);
return $response
@ -159,48 +149,58 @@ final class PodcastFeedAction implements SingleActionInterface
->withHeader('X-Robots-Tag', 'index, nofollow');
}
private function buildItemForEpisode(PodcastEpisode $episode, ServerRequest $request): RssItem
private function buildItemForEpisode(PodcastEpisode $episode, ServerRequest $request): array
{
$station = $request->getStation();
$episodeApi = $this->episodeApiGenerator->__invoke($episode, $request);
$rssItem = new RssItem();
$publishedAt = CarbonImmutable::createFromTimestamp($episodeApi->publish_at, $station->getTimezoneObject());
$rssItem->setGuid((new RssGuid())->setGuid($episodeApi->id));
$rssItem->setTitle($episodeApi->title);
$rssItem->setDescription($episodeApi->description);
$rssItem->setLink($episodeApi->link ?? $episodeApi->links['self']);
$rssItem->setPubDate((new DateTime())->setTimestamp($episode->getPublishAt()));
$rssEnclosure = new RssEnclosure();
$rssEnclosure->setUrl($episodeApi->links['download']);
$item = [
'title' => $episodeApi->title,
'link' => $episodeApi->link ?? $episodeApi->links['public'],
'description' => $episodeApi->description,
'enclosure' => [
'@url' => $episodeApi->links['download'],
],
'guid' => [
'@isPermaLink' => 'false',
'_' => $episodeApi->id,
],
'pubDate' => $publishedAt->toRssString(),
'itunes:image' => [
'@href' => $episodeApi->art,
],
'itunes:explicit' => $episodeApi->explicit ? 'true' : 'false',
];
$podcastMedia = $episode->getMedia();
if (null !== $podcastMedia) {
$rssEnclosure->setType($podcastMedia->getMimeType());
$rssEnclosure->setLength($podcastMedia->getLength());
$item['enclosure']['@length'] = $podcastMedia->getLength();
$item['enclosure']['@type'] = $podcastMedia->getMimeType();
}
$rssItem->setEnclosure($rssEnclosure);
$rssItem->addExtension(
(new ItunesItem())
->setExplicit($episode->getExplicit())
->setImage($episodeApi->art)
if (null !== $episodeApi->season_number) {
$item['itunes:season'] = (string)$episodeApi->season_number;
}
if (null !== $episodeApi->episode_number) {
$item['itunes:episode'] = (string)$episodeApi->episode_number;
}
return $item;
}
private function buildPodcastGuid(string $uri): string
{
$baseUri = rtrim(
str_replace(['https://', 'http://'], '', $uri),
'/'
);
return $rssItem;
}
private function buildItunesOwner(Podcast $podcast): ?ItunesOwner
{
if (empty($podcast->getAuthor()) && empty($podcast->getEmail())) {
return null;
}
$itunesOwner = new ItunesOwner();
$itunesOwner->setName($podcast->getAuthor());
$itunesOwner->setEmail($podcast->getEmail());
return $itunesOwner;
return (string)Uuid::uuid5(
self::PODCAST_NAMESPACE,
$baseUri
);
}
}

View File

@ -42,6 +42,13 @@ final class Podcast
#[OA\Property]
public string $description_short;
#[OA\Property(
description: "An array containing podcast-specific branding configuration",
type: "array",
items: new OA\Items()
)]
public array $branding_config;
#[OA\Property]
public string $language;

View File

@ -33,6 +33,12 @@ final class PodcastEpisode
#[OA\Property]
public bool $explicit = false;
#[OA\Property]
public ?int $season_number = null;
#[OA\Property]
public ?int $episode_number = null;
#[OA\Property]
public int $created_at;

View File

@ -48,6 +48,8 @@ final class PodcastApiGenerator
$return->description = $record->getDescription();
$return->description_short = Strings::truncateText($return->description, 200);
$return->branding_config = $record->getBrandingConfig()->toArray();
$return->language = $record->getLanguage();
try {
$locale = $request->getCustomization()->getLocale();

View File

@ -41,6 +41,9 @@ final class PodcastEpisodeApiGenerator
$return->description_short = Strings::truncateText($return->description, 100);
$return->explicit = $record->getExplicit();
$return->season_number = $record->getSeasonNumber();
$return->episode_number = $record->getEpisodeNumber();
$return->created_at = $record->getCreatedAt();
$return->publish_at = $record->getPublishAt();

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
final class Version20240421094525 extends AbstractMigration
{
public function getDescription(): string
{
return 'Expand podcast database fields.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE podcast ADD branding_config JSON DEFAULT NULL');
$this->addSql(
'ALTER TABLE podcast_episode ADD season_number INT DEFAULT NULL, ADD episode_number INT DEFAULT NULL'
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE podcast DROP branding_config');
$this->addSql('ALTER TABLE podcast_episode DROP season_number, DROP episode_number');
}
}

View File

@ -52,6 +52,9 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
#[Assert\NotBlank]
protected string $description;
#[ORM\Column(type: 'json', nullable: true)]
protected ?array $branding_config = null;
#[ORM\Column(length: 2)]
#[Assert\NotBlank]
protected string $language;
@ -147,6 +150,24 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
return $this;
}
public function getBrandingConfig(): PodcastBrandingConfiguration
{
return new PodcastBrandingConfiguration((array)$this->branding_config);
}
public function setBrandingConfig(
PodcastBrandingConfiguration|array $brandingConfig,
bool $forceOverwrite = false
): void {
if (is_array($brandingConfig)) {
$brandingConfig = new PodcastBrandingConfiguration(
$forceOverwrite ? $brandingConfig : array_merge((array)$this->branding_config, $brandingConfig)
);
}
$this->branding_config = $brandingConfig->toArray();
}
public function getLanguage(): string
{
return $this->language;

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Entity;
class PodcastBrandingConfiguration extends AbstractStationConfiguration
{
}

View File

@ -52,6 +52,12 @@ class PodcastEpisode implements IdentifiableEntityInterface
#[ORM\Column]
protected bool $explicit;
#[ORM\Column(nullable: true)]
protected ?int $season_number;
#[ORM\Column(nullable: true)]
protected ?int $episode_number;
#[ORM\Column]
protected int $created_at;
@ -151,6 +157,30 @@ class PodcastEpisode implements IdentifiableEntityInterface
return $this;
}
public function getSeasonNumber(): ?int
{
return $this->season_number;
}
public function setSeasonNumber(?int $season_number): self
{
$this->season_number = $season_number;
return $this;
}
public function getEpisodeNumber(): ?int
{
return $this->episode_number;
}
public function setEpisodeNumber(?int $episode_number): self
{
$this->episode_number = $episode_number;
return $this;
}
public function getCreatedAt(): int
{
return $this->created_at;

View File

@ -292,10 +292,7 @@ final class Icecast extends AbstractFrontend
}
}
$configString = Writer::toString($config, 'icecast');
// Strip the first line (the XML charset)
return substr($configString, strpos($configString, "\n") + 1);
return Writer::toString($config, 'icecast', false);
}
public function getCommand(Station $station): ?string

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Rss;
use MarcW\RssWriter\WriterRegistererInterface;
/**
* Placeholder class to write the Podcast namespace for PSP-1 compliance.
*/
class PodcastNamespaceWriter implements WriterRegistererInterface
{
public function getRegisteredWriters(): array
{
return [];
}
public function getRegisteredNamespaces(): array
{
return [
'podcast' => 'https://podcastindex.org/namespace/1.0',
];
}
}

View File

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

View File

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

View File

@ -8,32 +8,25 @@ declare(strict_types=1);
namespace App\Xml;
use RuntimeException;
use XMLWriter;
final class Writer
{
public static function toString(
array $config,
string $baseElement = 'xml-config'
): string {
return self::processConfig($config, $baseElement);
}
private static function processConfig(
array $config,
string $baseElement = 'xml-config'
string $baseElement = 'xml-config',
bool $includeOpeningTag = true
): string {
$writer = new XMLWriter();
$writer->openMemory();
$writer->setIndent(true);
$writer->setIndentString(str_repeat(' ', 4));
$writer->startDocument('1.0', 'UTF-8');
$writer->startElement($baseElement);
if ($includeOpeningTag) {
$writer->startDocument('1.0', 'UTF-8');
}
// Make sure attributes come first
uksort($config, [self::class, 'attributesFirst']);
$writer->startElement($baseElement);
foreach ($config as $sectionName => $data) {
if (!is_array($data)) {
@ -58,54 +51,50 @@ final class Writer
array $config,
XMLWriter $writer
): void {
$branchType = null;
// Ensure attributes come first.
uksort($config, [self::class, 'attributesFirst']);
$attributes = [];
$innerText = null;
foreach ($config as $key => $value) {
if ($branchType === null) {
if (is_numeric($key)) {
$branchType = 'numeric';
} else {
$writer->startElement($branchName);
$branchType = 'string';
if (str_starts_with((string)$key, '@')) {
$attributes[substr($key, 1)] = (string)$value;
unset($config[$key]);
} else {
if ('_' === $key) {
$innerText = (string)$value;
unset($config[$key]);
}
} elseif ($branchType !== (is_numeric($key) ? 'numeric' : 'string')) {
throw new RuntimeException('Mixing of string and numeric keys is not allowed');
}
}
if ($branchType === 'numeric') {
if (0 !== count($config) && array_is_list($config)) {
foreach ($config as $value) {
if (is_array($value)) {
self::addBranch($branchName, $value, $writer);
} else {
$writer->writeElement($branchName, (string)$value);
}
} else {
}
} else {
$writer->startElement($branchName);
foreach ($attributes as $attrKey => $attrVal) {
$writer->writeAttribute($attrKey, $attrVal);
}
if (null !== $innerText) {
$writer->text($innerText);
}
foreach ($config as $key => $value) {
/** @var string $key */
if (is_array($value)) {
self::addBranch($key, $value, $writer);
} elseif (str_starts_with($key, '@')) {
$writer->writeAttribute(substr($key, 1), (string)$value);
} else {
$writer->writeElement($key, (string)$value);
}
}
}
if ($branchType === 'string') {
$writer->endElement();
}
}
private static function attributesFirst(mixed $a, mixed $b): int
{
if (str_starts_with((string)$a, '@')) {
return -1;
}
if (str_starts_with((string)$b, '@')) {
return 1;
}
return 0;
}
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace <namespace>;
use Doctrine\DBAL\Schema\Schema;
use App\Entity\Migrations\AbstractMigration;
final class <className> extends AbstractMigration
{
@ -16,12 +15,12 @@ final class <className> extends AbstractMigration
public function up(Schema $schema): void
{
<up>
<up>
}
public function down(Schema $schema): void
{
<down>
<down>
}
<override>
<override>
}

View File

@ -3944,6 +3944,13 @@ components:
type: string
storage_location_id:
type: integer
source:
type: string
playlist_id:
type: integer
nullable: true
playlist_auto_publish:
type: boolean
title:
type: string
link:
@ -3953,6 +3960,10 @@ components:
type: string
description_short:
type: string
branding_config:
description: 'An array containing podcast-specific branding configuration'
type: array
items: { }
language:
type: string
language_name:
@ -3999,23 +4010,42 @@ components:
type: string
title:
type: string
link:
type: string
nullable: true
description:
type: string
description_short:
type: string
explicit:
type: boolean
season_number:
type: integer
nullable: true
episode_number:
type: integer
nullable: true
created_at:
type: integer
publish_at:
type: integer
is_published:
type: boolean
publish_at:
type: integer
nullable: true
has_media:
type: boolean
playlist_media_id:
type: string
nullable: true
playlist_media:
nullable: true
oneOf:
-
$ref: '#/components/schemas/Api_Song'
media:
$ref: '#/components/schemas/Api_PodcastMedia'
nullable: true
oneOf:
-
$ref: '#/components/schemas/Api_PodcastMedia'
has_custom_art:
type: boolean
art:
@ -5012,6 +5042,10 @@ components:
description: StationSchedule>
type: array
items: { }
podcasts:
description: Podcast>
type: array
items: { }
type: object
StationSchedule:
type: object