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

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

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

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