Compare commits

...

10 Commits

19 changed files with 345 additions and 256 deletions

View File

@ -52,7 +52,6 @@
"league/oauth2-client": "^2.6", "league/oauth2-client": "^2.6",
"league/plates": "^3.1", "league/plates": "^3.1",
"lstrojny/fxmlrpc": "dev-master", "lstrojny/fxmlrpc": "dev-master",
"marcw/rss-writer": "^0.4.0",
"matomo/device-detector": "^6", "matomo/device-detector": "^6",
"mezzio/mezzio-session": "^1.3", "mezzio/mezzio-session": "^1.3",
"mezzio/mezzio-session-cache": "^1.7", "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5bca0c641ba21645d05ab830394898ae", "content-hash": "77d8e4e6837ec47f02ea21d3f3cb767e",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -3505,64 +3505,6 @@
}, },
"time": "2023-08-22T06:06:43+00:00" "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", "name": "matomo/device-detector",
"version": "6.3.0", "version": "6.3.0",

View File

@ -205,6 +205,20 @@ const fields: DataTableField[] = [
sortable: true, sortable: true,
selectable: 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', key: 'actions',
label: $gettext('Actions'), label: $gettext('Actions'),

View File

@ -53,6 +53,28 @@
:label="$gettext('Contains explicit content')" :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.')" :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> </div>
</tab> </tab>
</template> </template>
@ -83,6 +105,8 @@ const {v$, tabClass} = useVuelidateOnFormTab(
publish_date: {}, publish_date: {},
publish_time: {}, publish_time: {},
explicit: {}, explicit: {},
season_number: {},
episode_number: {}
}, },
form, form,
{ {
@ -91,7 +115,9 @@ const {v$, tabClass} = useVuelidateOnFormTab(
description: '', description: '',
publish_date: '', publish_date: '',
publish_time: '', publish_time: '',
explicit: false explicit: false,
season_number: null,
episode_number: null
} }
); );
</script> </script>

View File

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

View File

@ -7,36 +7,20 @@ namespace App\Controller\Frontend\PublicPages;
use App\Controller\SingleActionInterface; use App\Controller\SingleActionInterface;
use App\Entity\ApiGenerator\PodcastApiGenerator; use App\Entity\ApiGenerator\PodcastApiGenerator;
use App\Entity\ApiGenerator\PodcastEpisodeApiGenerator; use App\Entity\ApiGenerator\PodcastEpisodeApiGenerator;
use App\Entity\Podcast;
use App\Entity\PodcastCategory; use App\Entity\PodcastCategory;
use App\Entity\PodcastEpisode; use App\Entity\PodcastEpisode;
use App\Exception\NotFoundException; use App\Exception\NotFoundException;
use App\Http\Response; use App\Http\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use App\Rss\PodcastNamespaceWriter; use App\Xml\Writer;
use DateTime; use Carbon\CarbonImmutable;
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 Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Ramsey\Uuid\Uuid;
final class PodcastFeedAction implements SingleActionInterface final class PodcastFeedAction implements SingleActionInterface
{ {
public const string PODCAST_NAMESPACE = 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6';
public function __construct( public function __construct(
private readonly PodcastApiGenerator $podcastApiGenerator, private readonly PodcastApiGenerator $podcastApiGenerator,
private readonly PodcastEpisodeApiGenerator $episodeApiGenerator private readonly PodcastEpisodeApiGenerator $episodeApiGenerator
@ -55,40 +39,78 @@ final class PodcastFeedAction implements SingleActionInterface
$podcast = $request->getPodcast(); $podcast = $request->getPodcast();
$channel = new RssChannel();
$channel->setTtl(5);
$channel->setLastBuildDate(new DateTime());
// Fetch podcast API feed. // Fetch podcast API feed.
$podcastApi = $this->podcastApiGenerator->__invoke($podcast, $request); $podcastApi = $this->podcastApiGenerator->__invoke($podcast, $request);
$channel->setTitle($podcastApi->title); $now = CarbonImmutable::now($station->getTimezoneObject());
$channel->setDescription($podcastApi->description);
$channel->setLink($podcastApi->link ?? $podcastApi->links['self']);
$channel->setLanguage($podcastApi->language);
$channel->setCategories( $rss = [
$podcast->getCategories()->map( '@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) { function (PodcastCategory $podcastCategory) {
$rssCategory = new RssCategory(); return (null === $podcastCategory->getSubTitle())
if (null === $podcastCategory->getSubTitle()) { ? $podcastCategory->getTitle()
$rssCategory->setTitle($podcastCategory->getTitle()); : $podcastCategory->getSubTitle();
} else {
$rssCategory->setTitle($podcastCategory->getSubTitle());
}
return $rssCategory;
} }
)->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) { 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. // Iterate through episodes.
$hasPublishedEpisode = false; $hasPublishedEpisode = false;
@ -105,53 +127,21 @@ final class PodcastFeedAction implements SingleActionInterface
$hasExplicitEpisode = true; $hasExplicitEpisode = true;
} }
$channel->addItem($this->buildItemForEpisode($episode, $request)); $channel['item'][] = $this->buildItemForEpisode($episode, $request);
} }
if (!$hasPublishedEpisode) { if (!$hasPublishedEpisode) {
throw NotFoundException::podcast(); throw NotFoundException::podcast();
} }
$itunesChannel = new ItunesChannel(); if ($hasExplicitEpisode) {
$itunesChannel->setExplicit($hasExplicitEpisode); $channel['itunes:explicit'] = 'true';
$itunesChannel->setImage($rssImage->getUrl()); }
$itunesChannel->setCategories(
$podcast->getCategories()->map(
function (PodcastCategory $podcastCategory) {
return (null === $podcastCategory->getSubTitle())
? $podcastCategory->getTitle()
: [
$podcastCategory->getTitle(),
$podcastCategory->getSubTitle(),
];
}
)->getValues()
);
$itunesChannel->setOwner($this->buildItunesOwner($podcast)); $rss['channel'] = $channel;
$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);
$response->getBody()->write( $response->getBody()->write(
$rssWriter->writeChannel($channel) Writer::toString($rss, 'rss')
); );
return $response return $response
@ -159,48 +149,58 @@ final class PodcastFeedAction implements SingleActionInterface
->withHeader('X-Robots-Tag', 'index, nofollow'); ->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); $episodeApi = $this->episodeApiGenerator->__invoke($episode, $request);
$rssItem = new RssItem(); $publishedAt = CarbonImmutable::createFromTimestamp($episodeApi->publish_at, $station->getTimezoneObject());
$rssItem->setGuid((new RssGuid())->setGuid($episodeApi->id)); $item = [
$rssItem->setTitle($episodeApi->title); 'title' => $episodeApi->title,
$rssItem->setDescription($episodeApi->description); 'link' => $episodeApi->link ?? $episodeApi->links['public'],
$rssItem->setLink($episodeApi->link ?? $episodeApi->links['self']); 'description' => $episodeApi->description,
'enclosure' => [
$rssItem->setPubDate((new DateTime())->setTimestamp($episode->getPublishAt())); '@url' => $episodeApi->links['download'],
],
$rssEnclosure = new RssEnclosure(); 'guid' => [
$rssEnclosure->setUrl($episodeApi->links['download']); '@isPermaLink' => 'false',
'_' => $episodeApi->id,
],
'pubDate' => $publishedAt->toRssString(),
'itunes:image' => [
'@href' => $episodeApi->art,
],
'itunes:explicit' => $episodeApi->explicit ? 'true' : 'false',
];
$podcastMedia = $episode->getMedia(); $podcastMedia = $episode->getMedia();
if (null !== $podcastMedia) { if (null !== $podcastMedia) {
$rssEnclosure->setType($podcastMedia->getMimeType()); $item['enclosure']['@length'] = $podcastMedia->getLength();
$rssEnclosure->setLength($podcastMedia->getLength()); $item['enclosure']['@type'] = $podcastMedia->getMimeType();
} }
$rssItem->setEnclosure($rssEnclosure);
$rssItem->addExtension( if (null !== $episodeApi->season_number) {
(new ItunesItem()) $item['itunes:season'] = (string)$episodeApi->season_number;
->setExplicit($episode->getExplicit()) }
->setImage($episodeApi->art) 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; return (string)Uuid::uuid5(
} self::PODCAST_NAMESPACE,
$baseUri
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;
} }
} }

View File

@ -42,6 +42,13 @@ final class Podcast
#[OA\Property] #[OA\Property]
public string $description_short; 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] #[OA\Property]
public string $language; public string $language;

View File

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

View File

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

View File

@ -41,6 +41,9 @@ final class PodcastEpisodeApiGenerator
$return->description_short = Strings::truncateText($return->description, 100); $return->description_short = Strings::truncateText($return->description, 100);
$return->explicit = $record->getExplicit(); $return->explicit = $record->getExplicit();
$return->season_number = $record->getSeasonNumber();
$return->episode_number = $record->getEpisodeNumber();
$return->created_at = $record->getCreatedAt(); $return->created_at = $record->getCreatedAt();
$return->publish_at = $record->getPublishAt(); $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] #[Assert\NotBlank]
protected string $description; protected string $description;
#[ORM\Column(type: 'json', nullable: true)]
protected ?array $branding_config = null;
#[ORM\Column(length: 2)] #[ORM\Column(length: 2)]
#[Assert\NotBlank] #[Assert\NotBlank]
protected string $language; protected string $language;
@ -147,6 +150,24 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
return $this; 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 public function getLanguage(): string
{ {
return $this->language; 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] #[ORM\Column]
protected bool $explicit; protected bool $explicit;
#[ORM\Column(nullable: true)]
protected ?int $season_number;
#[ORM\Column(nullable: true)]
protected ?int $episode_number;
#[ORM\Column] #[ORM\Column]
protected int $created_at; protected int $created_at;
@ -151,6 +157,30 @@ class PodcastEpisode implements IdentifiableEntityInterface
return $this; 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 public function getCreatedAt(): int
{ {
return $this->created_at; return $this->created_at;

View File

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

@ -8,32 +8,25 @@ declare(strict_types=1);
namespace App\Xml; namespace App\Xml;
use RuntimeException;
use XMLWriter; use XMLWriter;
final class Writer final class Writer
{ {
public static function toString( public static function toString(
array $config, array $config,
string $baseElement = 'xml-config' string $baseElement = 'xml-config',
): string { bool $includeOpeningTag = true
return self::processConfig($config, $baseElement);
}
private static function processConfig(
array $config,
string $baseElement = 'xml-config'
): string { ): string {
$writer = new XMLWriter(); $writer = new XMLWriter();
$writer->openMemory(); $writer->openMemory();
$writer->setIndent(true); $writer->setIndent(true);
$writer->setIndentString(str_repeat(' ', 4)); $writer->setIndentString(str_repeat(' ', 4));
$writer->startDocument('1.0', 'UTF-8'); if ($includeOpeningTag) {
$writer->startElement($baseElement); $writer->startDocument('1.0', 'UTF-8');
}
// Make sure attributes come first $writer->startElement($baseElement);
uksort($config, [self::class, 'attributesFirst']);
foreach ($config as $sectionName => $data) { foreach ($config as $sectionName => $data) {
if (!is_array($data)) { if (!is_array($data)) {
@ -58,54 +51,50 @@ final class Writer
array $config, array $config,
XMLWriter $writer XMLWriter $writer
): void { ): void {
$branchType = null; $attributes = [];
$innerText = null;
// Ensure attributes come first.
uksort($config, [self::class, 'attributesFirst']);
foreach ($config as $key => $value) { foreach ($config as $key => $value) {
if ($branchType === null) { if (str_starts_with((string)$key, '@')) {
if (is_numeric($key)) { $attributes[substr($key, 1)] = (string)$value;
$branchType = 'numeric'; unset($config[$key]);
} else { } else {
$writer->startElement($branchName); if ('_' === $key) {
$branchType = 'string'; $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)) { if (is_array($value)) {
self::addBranch($branchName, $value, $writer); self::addBranch($branchName, $value, $writer);
} else { } else {
$writer->writeElement($branchName, (string)$value); $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 */ /** @var string $key */
if (is_array($value)) { if (is_array($value)) {
self::addBranch($key, $value, $writer); self::addBranch($key, $value, $writer);
} elseif (str_starts_with($key, '@')) {
$writer->writeAttribute(substr($key, 1), (string)$value);
} else { } else {
$writer->writeElement($key, (string)$value); $writer->writeElement($key, (string)$value);
} }
} }
}
if ($branchType === 'string') {
$writer->endElement(); $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>; namespace <namespace>;
use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Schema;
use App\Entity\Migrations\AbstractMigration;
final class <className> extends AbstractMigration final class <className> extends AbstractMigration
{ {
@ -16,12 +15,12 @@ final class <className> extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
<up> <up>
} }
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
<down> <down>
} }
<override> <override>
} }

View File

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