Compare commits
10 Commits
a34d86f5c0
...
6feae35d81
Author | SHA1 | Date |
---|---|---|
Buster Neece | 6feae35d81 | |
Buster Neece | f12b3c0da2 | |
Buster Neece | d03dc1f277 | |
Buster Neece | ba4a71cd98 | |
Buster Neece | 820cc7ad03 | |
Buster Neece | d438be0a72 | |
Buster Neece | 1871b7a0cd | |
Buster Neece | 17e83547f7 | |
Buster Neece | c881a28be4 | |
Buster Neece | c84522105d |
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 & {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
class PodcastBrandingConfiguration extends AbstractStationConfiguration
|
||||
{
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue