Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • adaures/castopod
  • mkljczk/castopod-host
  • spaetz/castopod-host
  • PatrykMis/castopod
  • jonas/castopod
  • ajeremias/castopod
  • misuzu/castopod
  • KrzysztofDomanczyk/castopod
  • Behel/castopod
  • nebulon/castopod
  • ewen/castopod
  • NeoluxConsulting/castopod
  • nateritter/castopod-og
  • prcutler/castopod
14 results
Show changes
Showing
with 1256 additions and 914 deletions
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use App\Entities\Episode;
use CodeIgniter\Files\File;
use JamesHeinrich\GetID3\GetID3;
use CodeIgniter\I18n\Time;
use JamesHeinrich\GetID3\WriteTags;
use Modules\Media\FileManagers\FileManagerInterface;
if (!function_exists('get_file_tags')) {
/**
* Gets audio file metadata and ID3 info
*
* @return array<string, string|double|int>
*/
function get_file_tags(File $file): array
{
$getID3 = new GetID3();
$FileInfo = $getID3->analyze($file);
return [
'filesize' => $FileInfo['filesize'],
'mime_type' => $FileInfo['mime_type'],
'avdataoffset' => $FileInfo['avdataoffset'],
'playtime_seconds' => $FileInfo['playtime_seconds'],
];
}
}
if (!function_exists('write_audio_file_tags')) {
if (! function_exists('write_audio_file_tags')) {
/**
* Write audio file metadata / ID3 tags
*/
......@@ -43,45 +24,34 @@ if (!function_exists('write_audio_file_tags')) {
// Initialize getID3 tag-writing module
$tagwriter = new WriteTags();
$tagwriter->filename = media_path($episode->audio_file_path);
$tagwriter->filename = $episode->audio->file_name;
// set various options (optional)
$tagwriter->tagformats = ['id3v2.4'];
$tagwriter->tag_encoding = $TextEncoding;
$cover = new File(media_path($episode->image->id3_path));
/** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager');
$APICdata = file_get_contents($cover->getRealPath());
$APICdata = (string) $fileManager->getFileContents($episode->cover->id3_key);
// TODO: variables used for podcast specific tags
// $podcast_url = $episode->podcast->link;
// $podcast_feed_url = $episode->podcast->feed_url;
// $episode_media_url = $episode->link;
// $podcastUrl = $episode->podcast->link;
// $podcastFeedUrl = $episode->podcast->feed_url;
// $episodeMediaUrl = $episode->link;
// populate data array
$TagData = [
'title' => [$episode->title],
'artist' => [
empty($episode->podcast->publisher)
? $episode->podcast->owner_name
: $episode->podcast->publisher,
],
'album' => [$episode->podcast->title],
'year' => [
$episode->published_at
? $episode->published_at->format('Y')
: '',
],
'genre' => ['Podcast'],
'comment' => [$episode->description],
'track_number' => [strval($episode->number)],
'title' => [esc($episode->title)],
'artist' => [$episode->podcast->publisher ?? esc($episode->podcast->owner_name)],
'album' => [esc($episode->podcast->title)],
'year' => [$episode->published_at instanceof Time ? $episode->published_at->format('Y') : ''],
'genre' => ['Podcast'],
'comment' => [$episode->description],
'track_number' => [(string) $episode->number],
'copyright_message' => [$episode->podcast->copyright],
'publisher' => [
empty($episode->podcast->publisher)
? $episode->podcast->owner_name
: $episode->podcast->publisher,
],
'encoded_by' => ['Castopod'],
'publisher' => [$episode->podcast->publisher ?? esc($episode->podcast->owner_name)],
'encoded_by' => ['Castopod'],
// TODO: find a way to add the remaining tags for podcasts as the library doesn't seem to allow it
// 'website' => [$podcast_url],
......@@ -92,24 +62,23 @@ if (!function_exists('write_audio_file_tags')) {
];
$TagData['attached_picture'][] = [
'picturetypeid' => 2, // Cover. More: module.tag.id3v2.php
'data' => $APICdata,
'description' => 'cover',
'mime' => $cover->getMimeType(),
// picturetypeid == Cover. More: module.tag.id3v2.php
'picturetypeid' => 2,
'data' => $APICdata,
'description' => 'cover',
'mime' => $episode->cover->file_mimetype,
];
$tagwriter->tag_data = $TagData;
// write tags
if ($tagwriter->WriteTags()) {
echo 'Successfully wrote tags<br>';
if (!empty($tagwriter->warnings)) {
echo 'There were some warnings:<br>' .
implode('<br><br>', $tagwriter->warnings);
// Successfully wrote tags
if ($tagwriter->warnings !== []) {
log_message('warning', 'There were some warnings:' . PHP_EOL . implode(PHP_EOL, $tagwriter->warnings));
}
} else {
echo 'Failed to write tags!<br>' .
implode('<br><br>', $tagwriter->errors);
log_message('critical', 'Failed to write tags!' . PHP_EOL . implode(PHP_EOL, $tagwriter->errors));
}
}
}
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use Config\Services;
if (!function_exists('fetch_osm_location')) {
/**
* Fetches places from Nominatim OpenStreetMap
*/
function fetch_osm_location(string $locationName): ?array
{
$osmObject = null;
try {
$client = Services::curlrequest();
$response = $client->request(
'GET',
'https://nominatim.openstreetmap.org/search.php?q=' .
urlencode($locationName) .
'&polygon_geojson=1&format=jsonv2',
[
'headers' => [
'User-Agent' => 'Castopod/' . CP_VERSION,
'Accept' => 'application/json',
],
],
);
$places = json_decode(
$response->getBody(),
true,
512,
JSON_THROW_ON_ERROR,
);
$osmObject = [
'geo' =>
empty($places[0]['lat']) || empty($places[0]['lon'])
? null
: "geo:{$places[0]['lat']},{$places[0]['lon']}",
'osm_id' => empty($places[0]['osm_type'])
? null
: strtoupper(substr($places[0]['osm_type'], 0, 1)) .
$places[0]['osm_id'],
];
} catch (Exception $exception) {
//If things go wrong the show must go on
log_message('critical', $exception);
}
return $osmObject;
}
}
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\HTTP\Files\UploadedFile;
use Config\Services;
if (!function_exists('save_media')) {
/**
* Saves a file to the corresponding podcast folder in `public/media`
*
* @param File|UploadedFile $file
*/
function save_media(
File $file,
string $folder = '',
string $filename
): string {
if (($extension = $file->getExtension()) !== '') {
$filename = $filename . '.' . $extension;
}
$mediaRoot = config('App')->mediaRoot . '/' . $folder;
if (!file_exists($mediaRoot)) {
mkdir($mediaRoot, 0777, true);
touch($mediaRoot . '/index.html');
}
// move to media folder, overwrite file if already existing
$file->move($mediaRoot . '/', $filename, true);
return $folder . '/' . $filename;
}
}
if (!function_exists('download_file')) {
function download_file(string $fileUrl): File
{
$client = Services::curlrequest();
$response = $client->get($fileUrl, [
'headers' => [
'User-Agent' => 'Castopod/' . CP_VERSION,
],
]);
// redirect to new file location
$newFileUrl = $fileUrl;
while (
in_array(
$response->getStatusCode(),
[
ResponseInterface::HTTP_MOVED_PERMANENTLY,
ResponseInterface::HTTP_FOUND,
ResponseInterface::HTTP_SEE_OTHER,
ResponseInterface::HTTP_NOT_MODIFIED,
ResponseInterface::HTTP_TEMPORARY_REDIRECT,
ResponseInterface::HTTP_PERMANENT_REDIRECT,
],
true,
)
) {
$newFileUrl = trim($response->getHeader('location')->getValue());
$response = $client->get($newFileUrl, [
'headers' => [
'User-Agent' => 'Castopod/' . CP_VERSION,
],
'http_errors' => false,
]);
}
$tmpFilename =
time() .
'_' .
bin2hex(random_bytes(10)) .
'.' .
pathinfo(parse_url($newFileUrl, PHP_URL_PATH), PATHINFO_EXTENSION);
$tmpFilePath = WRITEPATH . 'uploads/' . $tmpFilename;
file_put_contents($tmpFilePath, $response->getBody());
return new File($tmpFilePath);
}
}
if (!function_exists('media_path')) {
/**
* Prefixes the root media path to a given uri
*
* @param string|array $uri URI string or array of URI segments
*/
function media_path($uri = ''): string
{
// convert segment array to string
if (is_array($uri)) {
$uri = implode('/', $uri);
}
$uri = trim($uri, '/');
return config('App')->mediaRoot . '/' . $uri;
}
}
if (!function_exists('media_base_url')) {
/**
* Return the media base URL to use in views
*
* @param string|string[] $uri URI string or array of URI segments
*/
function media_base_url($uri = ''): string
{
// convert segment array to string
if (is_array($uri)) {
$uri = implode('/', $uri);
}
$uri = trim($uri, '/');
return rtrim(config('App')->mediaBaseURL, '/') .
'/' .
config('App')->mediaRoot .
'/' .
$uri;
}
}
<?php
declare(strict_types=1);
use App\Entities\Person;
use App\Entities\Podcast;
use Cocur\Slugify\Slugify;
use Config\Images;
use Modules\Media\Entities\Image;
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (!function_exists('get_browser_language')) {
if (! function_exists('get_browser_language')) {
/**
* Gets the browser default language using the request header key `HTTP_ACCEPT_LANGUAGE`
* Gets the browser default language using the request header key `HTTP_ACCEPT_LANGUAGE`. Returns Castopod's default
* locale if `HTTP_ACCEPT_LANGUAGE` is null.
*
* @return string|null ISO 639-1 language code or null
* @return string ISO 639-1 language code
*/
function get_browser_language(string $httpAcceptLanguage): ?string
function get_browser_language(?string $httpAcceptLanguage = null): string
{
if ($httpAcceptLanguage === null) {
return config('App')->defaultLocale;
}
$langs = explode(',', $httpAcceptLanguage);
if (!empty($langs)) {
return substr($langs[0], 0, 2);
return substr($langs[0], 0, 2);
}
}
if (! function_exists('slugify')) {
function slugify(string $text, int $maxLength = 128): string
{
// trim text to the nearest whole word if too long
if (strlen($text) > $maxLength) {
$text = substr($text, 0, strrpos(substr($text, 0, $maxLength), ' '));
}
$slugify = new Slugify();
return $slugify->slugify($text);
}
}
//--------------------------------------------------------------------
if (! function_exists('format_duration')) {
/**
* Formats duration in seconds to an hh:mm:ss string.
*
* ⚠️ This uses php's gmdate function so any duration > 86000 seconds (24 hours) will not be formatted properly.
*
* @param int $seconds seconds to format
*/
function format_duration(int $seconds, bool $showLeadingZeros = false): string
{
if ($showLeadingZeros) {
return gmdate('H:i:s', $seconds);
}
if ($seconds < 60) {
return '0:' . sprintf('%02d', $seconds);
}
return null;
if ($seconds < 3600) {
// < 1 hour: returns MM:SS
return ltrim(gmdate('i:s', $seconds), '0');
}
if ($seconds < 36000) {
// < 10 hours: returns H:MM:SS
return ltrim(gmdate('H:i:s', $seconds), '0');
}
return gmdate('H:i:s', $seconds);
}
}
if (!function_exists('startsWith')) {
if (! function_exists('format_duration_symbol')) {
/**
* Check if a string starts with some characters
* Formats duration in seconds to an hh(h) mm(min) ss(s) string. Doesn't show leading zeros if any.
*
* ⚠️ This uses php's gmdate function so any duration > 86000 seconds (24 hours) will not be formatted properly.
*
* @param int $seconds seconds to format
*/
function startsWith(string $string, string $query): bool
function format_duration_symbol(int $seconds): string
{
return substr($string, 0, strlen($query)) === $query;
if ($seconds < 60) {
return $seconds . 's';
}
if ($seconds < 3600) {
// < 1 hour: returns MM:SS
return ltrim(gmdate('i\m\i\n s\s', $seconds), '0');
}
if ($seconds < 36000) {
// < 10 hours: returns H:MM:SS
return ltrim(gmdate('h\h i\m\i\n s\s', $seconds), '0');
}
return gmdate('h\h i\m\i\n s\s', $seconds);
}
}
if (!function_exists('slugify')) {
function slugify($text)
//--------------------------------------------------------------------
if (! function_exists('generate_random_salt')) {
function generate_random_salt(int $length = 64): string
{
if (empty($text)) {
return 'n-a';
}
// replace non letter or digits by -
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
$unwanted_array = [
'Š' => 'S',
'š' => 's',
'Đ' => 'Dj',
'đ' => 'dj',
'Ž' => 'Z',
'ž' => 'z',
'Č' => 'C',
'č' => 'c',
'Ć' => 'C',
'ć' => 'c',
'À' => 'A',
'Á' => 'A',
'Â' => 'A',
'Ã' => 'A',
'Ä' => 'A',
'Å' => 'A',
'Æ' => 'AE',
'Ç' => 'C',
'È' => 'E',
'É' => 'E',
'Ê' => 'E',
'Ë' => 'E',
'Ì' => 'I',
'Í' => 'I',
'Î' => 'I',
'Ï' => 'I',
'Ñ' => 'N',
'Ò' => 'O',
'Ó' => 'O',
'Ô' => 'O',
'Õ' => 'O',
'Ö' => 'O',
'Ø' => 'O',
'Œ' => 'OE',
'Ù' => 'U',
'Ú' => 'U',
'Û' => 'U',
'Ü' => 'U',
'Ý' => 'Y',
'Þ' => 'B',
'ß' => 'Ss',
'à' => 'a',
'á' => 'a',
'â' => 'a',
'ã' => 'a',
'ä' => 'a',
'å' => 'a',
'æ' => 'ae',
'ç' => 'c',
'è' => 'e',
'é' => 'e',
'ê' => 'e',
'ë' => 'e',
'ì' => 'i',
'í' => 'i',
'î' => 'i',
'ï' => 'i',
'ð' => 'o',
'ñ' => 'n',
'ò' => 'o',
'ó' => 'o',
'ô' => 'o',
'õ' => 'o',
'ö' => 'o',
'ø' => 'o',
'œ' => 'OE',
'ù' => 'u',
'ú' => 'u',
'û' => 'u',
'ý' => 'y',
'þ' => 'b',
'ÿ' => 'y',
'Ŕ' => 'R',
'ŕ' => 'r',
'/' => '-',
' ' => '-',
];
$text = strtr($text, $unwanted_array);
// transliterate
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
// remove unwanted characters
$text = preg_replace('~[^\-\w]+~', '', $text);
// trim
$text = trim($text, '-');
// remove duplicate -
$text = preg_replace('~-+~', '-', $text);
// lowercase
$text = strtolower($text);
return $text;
$salt = '';
while (strlen($salt) < $length) {
$charNumber = random_int(33, 126);
// Exclude " ' \ `
if (! in_array($charNumber, [34, 39, 92, 96], true)) {
$salt .= chr($charNumber);
}
}
return $salt;
}
}
//--------------------------------------------------------------------
if (!function_exists('format_duration')) {
if (! function_exists('file_upload_max_size')) {
/**
* Formats duration in seconds to an hh:mm:ss string
*
* @param int $seconds seconds to format
* Returns a file size limit in bytes based on the PHP upload_max_filesize and post_max_size Adapted from:
* https://stackoverflow.com/a/25370978
*/
function format_duration(int $seconds, string $separator = ':'): string
function file_upload_max_size(): float
{
return sprintf(
'%02d%s%02d%s%02d',
floor($seconds / 3600),
$separator,
($seconds / 60) % 60,
$separator,
$seconds % 60,
);
static $max_size = -1;
if ($max_size < 0) {
// Start with post_max_size.
$post_max_size = parse_size((string) ini_get('post_max_size'));
if ($post_max_size > 0) {
$max_size = $post_max_size;
}
// If upload_max_size is less, then reduce. Except if upload_max_size is
// zero, which indicates no limit.
$upload_max = parse_size((string) ini_get('upload_max_filesize'));
if ($upload_max > 0 && $upload_max < $max_size) {
$max_size = $upload_max;
}
}
return $max_size;
}
}
//--------------------------------------------------------------------
if (! function_exists('parse_size')) {
function parse_size(string $size): float
{
$unit = (string) preg_replace('~[^bkmgtpezy]~i', '', $size); // Remove the non-unit characters from the size.
$size = (float) preg_replace('~[^0-9\.]~', '', $size); // Remove the non-numeric characters from the size.
if ($unit !== '') {
// Find the position of the unit in the ordered string which is the power of magnitude to multiply a kilobyte by.
return round($size * 1024 ** ((float) stripos('bkmgtpezy', $unit[0])));
}
return round($size);
}
}
if (! function_exists('format_bytes')) {
/**
* Adapted from https://stackoverflow.com/a/2510459
*/
function formatBytes(float $bytes, bool $is_binary = false, int $precision = 2): string
{
$units = $is_binary ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] : ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log($is_binary ? 1024 : 1000));
$pow = min($pow, count($units) - 1);
$bytes /= ($is_binary ? 1024 : 1000) ** $pow;
return round($bytes, $precision) . $units[$pow];
}
}
if (! function_exists('get_site_icon_url')) {
function get_site_icon_url(string $size): string
{
if (config('App')->siteIcon['ico'] === service('settings')->get('App.siteIcon')['ico']) {
// return default site icon url
return base_url(service('settings')->get('App.siteIcon')[$size]);
}
return service('file_manager')->getUrl(service('settings')->get('App.siteIcon')[$size]);
}
}
if (! function_exists('get_podcast_banner')) {
function get_podcast_banner_url(Podcast $podcast, string $size): string
{
if (! $podcast->banner instanceof Image) {
$defaultBanner = config('Images')
->podcastBannerDefaultPaths[service('settings')->get('App.theme')] ?? config(
Images::class,
)->podcastBannerDefaultPaths['default'];
$sizes = config('Images')
->podcastBannerSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
// return default site icon url
return base_url(
change_file_path($defaultBanner['path'], '_' . $size, $sizeConfig['extension'] ?? null),
);
}
$sizeKey = $size . '_url';
return $podcast->banner->{$sizeKey};
}
}
if (! function_exists('get_podcast_banner_mimetype')) {
function get_podcast_banner_mimetype(Podcast $podcast, string $size): string
{
if (! $podcast->banner instanceof Image) {
$sizes = config('Images')
->podcastBannerSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
// return default site icon url
return array_key_exists('mimetype', $sizeConfig) ? $sizeConfig['mimetype'] : config(
Images::class,
)->podcastBannerDefaultMimeType;
}
$mimetype = $size . '_mimetype';
return $podcast->banner->{$mimetype};
}
}
if (! function_exists('get_avatar_url')) {
function get_avatar_url(Person $person, string $size): string
{
if (! $person->avatar instanceof Image) {
$defaultAvatarPath = config('Images')
->avatarDefaultPath;
$sizes = config('Images')
->personAvatarSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
// return default avatar url
return base_url(change_file_path($defaultAvatarPath, '_' . $size, $sizeConfig['extension'] ?? null));
}
$sizeKey = $size . '_url';
return $person->avatar->{$sizeKey};
}
}
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use App\Models\PageModel;
if (!function_exists('render_page_links')) {
if (! function_exists('render_page_links')) {
/**
* Returns instance pages as links inside nav tag
*
* @return string html pages navigation
*/
function render_page_links(string $class = null): string
function render_page_links(?string $class = null, ?string $podcastHandle = null): string
{
$pages = (new PageModel())->findAll();
$links = anchor(route_to('home'), lang('Common.home'), [
'class' => 'px-2 underline hover:no-underline',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
if ($podcastHandle !== null) {
$links .= anchor(route_to('podcast-links', $podcastHandle), lang('Podcast.links'), [
'class' => 'px-2 py-1 underline hover:no-underline',
]);
}
$links .= anchor(route_to('credits'), lang('Person.credits'), [
'class' => 'px-2 underline hover:no-underline',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
$links .= anchor(route_to('map'), lang('Page.map.title'), [
'class' => 'px-2 py-1 underline hover:no-underline',
]);
foreach ($pages as $page) {
$links .= anchor($page->link, $page->title, [
'class' => 'px-2 underline hover:no-underline',
$links .= anchor($page->link, esc($page->title), [
'class' => 'px-2 py-1 underline hover:no-underline',
]);
}
// if set in .env, add legal notice link at the end of page links
if (config('App')->legalNoticeURL !== null) {
$links .= anchor(config('App')->legalNoticeURL, lang('Common.legal_notice'), [
'class' => 'px-2 py-1 underline hover:no-underline',
'target' => '_blank',
'rel' => 'noopener noreferrer',
]);
}
......
<?php
/**
* @copyright 2021 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use App\Entities\Person;
use App\Entities\EpisodePerson;
use App\Entities\PodcastPerson;
if (!function_exists('construct_person_array')) {
/**
* Fetches persons from an episode
*
* @param Person[]|PodcastPerson[]|EpisodePerson[] $persons
*/
function construct_person_array(array $persons, array &$personsArray): void
{
foreach ($persons as $person) {
if (array_key_exists($person->id, $personsArray)) {
$personsArray[$person->id]['roles'] .=
empty($person->person_group) || empty($person->person_role)
? ''
: (empty($personsArray[$person->id]['roles'])
? ''
: ', ') .
lang(
'PersonsTaxonomy.persons.' .
$person->person_group .
'.roles.' .
$person->person_role .
'.label',
);
} else {
$personsArray[$person->person->id] = [
'full_name' => $person->person->full_name,
'information_url' => $person->person->information_url,
'thumbnail_url' => $person->person->image->thumbnail_url,
'roles' =>
empty($person->person_group) ||
empty($person->person_role)
? ''
: lang(
'PersonsTaxonomy.persons.' .
$person->person_group .
'.roles.' .
$person->person_role .
'.label',
),
];
}
}
}
}
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use App\Libraries\SimpleRSSElement;
use App\Entities\Category;
use App\Entities\Location;
use App\Entities\Podcast;
use App\Libraries\RssFeed;
use App\Models\PodcastModel;
use CodeIgniter\I18n\Time;
use Config\Mimes;
use App\Entities\Podcast;
use App\Entities\Category;
use Modules\Media\Entities\Chapters;
use Modules\Media\Entities\Transcript;
use Modules\Plugins\Core\Plugins;
use Modules\PremiumPodcasts\Entities\Subscription;
if (!function_exists('get_rss_feed')) {
if (! function_exists('get_rss_feed')) {
/**
* Generates the rss feed for a given podcast entity
*
* @param string $serviceSlug The name of the service that fetches the RSS feed for future reference when the audio file is eventually downloaded
* @return string rss feed as xml
*/
function get_rss_feed(Podcast $podcast, ?string $serviceSlug = null): string
{
$episodes = $podcast->episodes;
function get_rss_feed(
Podcast $podcast,
string $serviceSlug = '',
?Subscription $subscription = null,
?string $token = null,
): string {
/** @var Plugins $plugins */
$plugins = service('plugins');
$itunes_namespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
$episodes = $podcast->episodes;
$podcast_namespace =
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md';
$rss = new RssFeed();
$rss = new SimpleRSSElement(
"<?xml version='1.0' encoding='utf-8'?><rss version='2.0' xmlns:itunes='{$itunes_namespace}' xmlns:podcast='{$podcast_namespace}' xmlns:content='http://purl.org/rss/1.0/modules/content/'></rss>",
);
$plugins->rssBeforeChannel($podcast);
$channel = $rss->addChild('channel');
$atom_link = $channel->addChild(
'atom:link',
null,
'http://www.w3.org/2005/Atom',
);
$atom_link->addAttribute('href', $podcast->feed_url);
$atom_link->addAttribute('rel', 'self');
$atom_link->addAttribute('type', 'application/rss+xml');
$atomLink = $channel->addChild('link', null, RssFeed::ATOM_NAMESPACE);
$atomLink->addAttribute('href', $podcast->feed_url);
$atomLink->addAttribute('rel', 'self');
$atomLink->addAttribute('type', 'application/rss+xml');
// websub: add links to hubs defined in config
$websubHubs = config('WebSub')
->hubs;
foreach ($websubHubs as $websubHub) {
$atomLinkHub = $channel->addChild('link', null, RssFeed::ATOM_NAMESPACE);
$atomLinkHub->addAttribute('href', $websubHub);
$atomLinkHub->addAttribute('rel', 'hub');
$atomLinkHub->addAttribute('type', 'application/rss+xml');
}
if ($podcast->new_feed_url !== null) {
$channel->addChild(
'new-feed-url',
$podcast->new_feed_url,
$itunes_namespace,
);
$channel->addChild('new-feed-url', $podcast->new_feed_url, RssFeed::ITUNES_NAMESPACE);
}
// the last build date corresponds to the creation of the feed.xml cache
$channel->addChild(
'lastBuildDate',
(new Time('now'))->format(DATE_RFC1123),
);
$channel->addChild(
'generator',
'Castopod Host - https://castopod.org/',
);
$channel->addChild('lastBuildDate', (new Time('now'))->format(DATE_RFC1123));
$channel->addChild('generator', 'Castopod - https://castopod.org/');
$channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html');
$channel->addChild('title', $podcast->title);
if ($podcast->guid === '') {
// FIXME: guid shouldn't be empty here as it should be filled upon Podcast creation
$uuid = service('uuid');
// 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6' is the uuid of the podcast namespace
$podcast->guid = $uuid->uuid5('ead4c236-bf58-58c6-a2c6-a6b28d128cb6', $podcast->feed_url)
->toString();
(new PodcastModel())->save($podcast);
}
$channel->addChild('guid', $podcast->guid, RssFeed::PODCAST_NAMESPACE);
$channel->addChild('title', $podcast->title, null, false);
$channel->addChildWithCDATA('description', $podcast->description_html);
$itunes_image = $channel->addChild('image', null, $itunes_namespace);
$itunesImage = $channel->addChild('image', null, RssFeed::ITUNES_NAMESPACE);
// FIXME: This should be downsized to 1400x1400
$itunes_image->addAttribute('href', $podcast->image->url);
$itunesImage->addAttribute('href', $podcast->cover->feed_url);
$channel->addChild('language', $podcast->language_code);
if ($podcast->location !== null) {
$locationElement = $channel->addChild(
'location',
htmlspecialchars($podcast->location->name),
$podcast_namespace,
);
if ($podcast->location instanceof Location) {
$locationElement = $channel->addChild('location', $podcast->location->name, RssFeed::PODCAST_NAMESPACE);
if ($podcast->location->geo !== null) {
$locationElement->addAttribute('geo', $podcast->location->geo);
}
if ($podcast->location->osm_id !== null) {
$locationElement->addAttribute(
'osm',
$podcast->location->osm_id,
);
if ($podcast->location->osm !== null) {
$locationElement->addAttribute('osm', $podcast->location->osm);
}
}
if ($podcast->payment_pointer !== null) {
$valueElement = $channel->addChild(
'value',
null,
$podcast_namespace,
);
$valueElement->addAttribute('type', 'webmonetization');
$valueElement->addAttribute('method', '');
$valueElement->addAttribute('suggested', '');
$recipientElement = $valueElement->addChild(
'valueRecipient',
null,
$podcast_namespace,
);
$recipientElement->addAttribute('name', $podcast->owner_name);
$recipientElement->addAttribute('type', 'ILP');
$recipientElement->addAttribute(
'address',
$podcast->payment_pointer,
);
$recipientElement->addAttribute('split', '100');
}
$channel
->addChild(
'locked',
$podcast->is_locked ? 'yes' : 'no',
$podcast_namespace,
)
->addChild('locked', $podcast->is_locked ? 'yes' : 'no', RssFeed::PODCAST_NAMESPACE)
->addAttribute('owner', $podcast->owner_email);
if ($podcast->imported_feed_url !== null) {
$channel->addChild(
'previousUrl',
$podcast->imported_feed_url,
$podcast_namespace,
);
$channel->addChild('previousUrl', $podcast->imported_feed_url, RssFeed::PODCAST_NAMESPACE);
}
foreach ($podcast->podcasting_platforms as $podcastingPlatform) {
$podcastingPlatformElement = $channel->addChild(
'id',
null,
$podcast_namespace,
);
$podcastingPlatformElement->addAttribute(
'platform',
$podcastingPlatform->slug,
);
if ($podcastingPlatform->link_content !== null) {
$podcastingPlatformElement->addAttribute(
'id',
$podcastingPlatform->link_content,
);
$podcastingPlatformElement = $channel->addChild('id', null, RssFeed::PODCAST_NAMESPACE);
$podcastingPlatformElement->addAttribute('platform', $podcastingPlatform->slug);
if ($podcastingPlatform->account_id !== null) {
$podcastingPlatformElement->addAttribute('id', $podcastingPlatform->account_id);
}
if ($podcastingPlatform->link_url !== null) {
$podcastingPlatformElement->addAttribute(
'url',
htmlspecialchars($podcastingPlatform->link_url),
);
$podcastingPlatformElement->addAttribute('url', $podcastingPlatform->link_url);
}
}
$castopodSocialElement = $channel->addChild('social', null, RssFeed::PODCAST_NAMESPACE);
$castopodSocialElement->addAttribute('priority', '1');
$castopodSocialElement->addAttribute('platform', 'castopod');
$castopodSocialElement->addAttribute('protocol', 'activitypub');
$castopodSocialElement->addAttribute('accountId', "@{$podcast->actor->username}@{$podcast->actor->domain}");
$castopodSocialElement->addAttribute('accountUrl', $podcast->link);
foreach ($podcast->social_platforms as $socialPlatform) {
$socialPlatformElement = $channel->addChild(
'social',
$socialPlatform->link_content,
$podcast_namespace,
);
$socialPlatformElement->addAttribute(
'platform',
$socialElement = $channel->addChild('social', null, RssFeed::PODCAST_NAMESPACE);
$socialElement->addAttribute('priority', '2');
$socialElement->addAttribute('platform', $socialPlatform->slug);
// TODO: get activitypub info somewhere else
if (in_array(
$socialPlatform->slug,
);
['mastodon', 'peertube', 'funkwhale', 'misskey', 'mobilizon', 'pixelfed', 'plume', 'writefreely'],
true,
)) {
$socialElement->addAttribute('protocol', 'activitypub');
} else {
$socialElement->addAttribute('protocol', $socialPlatform->slug);
}
if ($socialPlatform->account_id !== null) {
$socialElement->addAttribute('accountId', esc($socialPlatform->account_id));
}
if ($socialPlatform->link_url !== null) {
$socialPlatformElement->addAttribute(
'url',
htmlspecialchars($socialPlatform->link_url),
$socialElement->addAttribute('accountUrl', esc($socialPlatform->link_url));
}
if ($socialPlatform->slug === 'mastodon') {
$socialSignUpelement = $socialElement->addChild('socialSignUp', null, RssFeed::PODCAST_NAMESPACE);
$socialSignUpelement->addAttribute('priority', '1');
$socialSignUpelement->addAttribute(
'homeUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/public',
);
$socialSignUpelement->addAttribute(
'signUpUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/auth/sign_up',
);
$castopodSocialSignUpelement = $castopodSocialElement->addChild(
'socialSignUp',
null,
RssFeed::PODCAST_NAMESPACE,
);
$castopodSocialSignUpelement->addAttribute('priority', '1');
$castopodSocialSignUpelement->addAttribute(
'homeUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/public',
);
$castopodSocialSignUpelement->addAttribute(
'signUpUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/auth/sign_up',
);
}
}
......@@ -169,65 +191,33 @@ if (!function_exists('get_rss_feed')) {
foreach ($podcast->funding_platforms as $fundingPlatform) {
$fundingPlatformElement = $channel->addChild(
'funding',
$fundingPlatform->link_content,
$podcast_namespace,
);
$fundingPlatformElement->addAttribute(
'platform',
$fundingPlatform->slug,
$fundingPlatform->account_id,
RssFeed::PODCAST_NAMESPACE,
);
$fundingPlatformElement->addAttribute('platform', $fundingPlatform->slug);
if ($fundingPlatform->link_url !== null) {
$fundingPlatformElement->addAttribute(
'url',
htmlspecialchars($fundingPlatform->link_url),
);
$fundingPlatformElement->addAttribute('url', $fundingPlatform->link_url);
}
}
foreach ($podcast->persons as $podcastPerson) {
$podcastPersonElement = $channel->addChild(
'person',
htmlspecialchars($podcastPerson->person->full_name),
$podcast_namespace,
);
foreach ($podcast->persons as $person) {
foreach ($person->roles as $role) {
$personElement = $channel->addChild('person', $person->full_name, RssFeed::PODCAST_NAMESPACE);
if (
$podcastPerson->person_role !== null &&
$podcastPerson->person_group !== null
) {
$podcastPersonElement->addAttribute(
$personElement->addAttribute('img', get_avatar_url($person, 'medium'));
if ($person->information_url !== null) {
$personElement->addAttribute('href', $person->information_url);
}
$personElement->addAttribute(
'role',
htmlspecialchars(
lang(
"PersonsTaxonomy.persons.{$podcastPerson->person_group}.roles.{$podcastPerson->person_role}.label",
[],
'en',
),
),
lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en'),
);
}
if ($podcastPerson->person_group !== null) {
$podcastPersonElement->addAttribute(
$personElement->addAttribute(
'group',
htmlspecialchars(
lang(
"PersonsTaxonomy.persons.{$podcastPerson->person_group}.label",
[],
'en',
),
),
);
}
$podcastPersonElement->addAttribute(
'img',
$podcastPerson->person->image->large_url,
);
if ($podcastPerson->person->information_url !== null) {
$podcastPersonElement->addAttribute(
'href',
$podcastPerson->person->information_url,
lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en'),
);
}
}
......@@ -241,102 +231,77 @@ if (!function_exists('get_rss_feed')) {
$channel->addChild(
'explicit',
$podcast->parental_advisory === 'explicit' ? 'true' : 'false',
$itunes_namespace,
RssFeed::ITUNES_NAMESPACE,
);
$channel->addChild(
'author',
$podcast->publisher ? $podcast->publisher : $podcast->owner_name,
$itunes_namespace,
);
$channel->addChild('author', $podcast->publisher ?: $podcast->owner_name, RssFeed::ITUNES_NAMESPACE, false);
$channel->addChild('link', $podcast->link);
$owner = $channel->addChild('owner', null, $itunes_namespace);
$owner->addChild('name', $podcast->owner_name, $itunes_namespace);
$owner = $channel->addChild('owner', null, RssFeed::ITUNES_NAMESPACE);
$owner->addChild('email', $podcast->owner_email, $itunes_namespace);
$owner->addChild('name', $podcast->owner_name, RssFeed::ITUNES_NAMESPACE, false);
$owner->addChild('email', $podcast->owner_email, RssFeed::ITUNES_NAMESPACE);
$channel->addChild('type', $podcast->type, $itunes_namespace);
$channel->addChild('type', $podcast->type, RssFeed::ITUNES_NAMESPACE);
$podcast->copyright &&
$channel->addChild('copyright', $podcast->copyright);
$podcast->is_blocked &&
$channel->addChild('block', 'Yes', $itunes_namespace);
$podcast->is_completed &&
$channel->addChild('complete', 'Yes', $itunes_namespace);
if ($podcast->is_blocked || $subscription instanceof Subscription) {
$channel->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
if ($podcast->is_completed) {
$channel->addChild('complete', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
$image = $channel->addChild('image');
$image->addChild('url', $podcast->image->feed_url);
$image->addChild('title', $podcast->title);
$image->addChild('url', $podcast->cover->feed_url);
$image->addChild('title', $podcast->title, null, false);
$image->addChild('link', $podcast->link);
if ($podcast->custom_rss !== null) {
array_to_rss(
[
'elements' => $podcast->custom_rss,
],
$channel,
);
}
// run plugins hook at the end
$plugins->rssAfterChannel($podcast, $channel);
foreach ($episodes as $episode) {
if ($episode->is_premium && ! $subscription instanceof Subscription) {
continue;
}
$plugins->rssBeforeItem($episode);
$item = $channel->addChild('item');
$item->addChild('title', $episode->title);
$item->addChild('title', $episode->title, null, false);
$enclosure = $item->addChild('enclosure');
$enclosureParams = implode('&', array_filter([
$episode->is_premium ? 'token=' . $token : null,
$serviceSlug !== '' ? '_from=' . urlencode($serviceSlug) : null,
]));
$enclosure->addAttribute(
'url',
$episode->audio_file_analytics_url .
($serviceSlug === ''
? ''
: '?_from=' . urlencode($serviceSlug)),
$episode->audio_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams),
);
$enclosure->addAttribute('length', $episode->audio_file_size);
$enclosure->addAttribute('type', $episode->audio_file_mimetype);
$enclosure->addAttribute('length', (string) $episode->audio->file_size);
$enclosure->addAttribute('type', $episode->audio->file_mimetype);
$item->addChild('guid', $episode->guid);
$item->addChild(
'pubDate',
$episode->published_at->format(DATE_RFC1123),
);
if ($episode->location !== null) {
$locationElement = $item->addChild(
'location',
htmlspecialchars($episode->location->name),
$podcast_namespace,
);
$item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123));
if ($episode->location instanceof Location) {
$locationElement = $item->addChild('location', $episode->location->name, RssFeed::PODCAST_NAMESPACE);
if ($episode->location->geo !== null) {
$locationElement->addAttribute(
'geo',
$episode->location->geo,
);
$locationElement->addAttribute('geo', $episode->location->geo);
}
if ($episode->location->osm_id !== null) {
$locationElement->addAttribute(
'osm',
$episode->location->osm_id,
);
if ($episode->location->osm !== null) {
$locationElement->addAttribute('osm', $episode->location->osm);
}
}
$item->addChildWithCDATA(
'description',
$episode->getDescriptionHtml($serviceSlug),
);
$item->addChild(
'duration',
$episode->audio_file_duration,
$itunes_namespace,
);
$item->addChildWithCDATA('description', $episode->description_html);
$item->addChild('duration', (string) round($episode->audio->duration), RssFeed::ITUNES_NAMESPACE);
$item->addChild('link', $episode->link);
$episode_itunes_image = $item->addChild(
'image',
null,
$itunes_namespace,
);
$episode_itunes_image->addAttribute(
'href',
$episode->image->feed_url,
);
$episodeItunesImage = $item->addChild('image', null, RssFeed::ITUNES_NAMESPACE);
$episodeItunesImage->addAttribute('href', $episode->cover->feed_url);
$episode->parental_advisory &&
$item->addChild(
......@@ -344,236 +309,135 @@ if (!function_exists('get_rss_feed')) {
$episode->parental_advisory === 'explicit'
? 'true'
: 'false',
$itunes_namespace,
RssFeed::ITUNES_NAMESPACE,
);
$episode->number &&
$item->addChild('episode', $episode->number, $itunes_namespace);
$item->addChild('episode', (string) $episode->number, RssFeed::ITUNES_NAMESPACE);
$episode->season_number &&
$item->addChild(
'season',
$episode->season_number,
$itunes_namespace,
$item->addChild('season', (string) $episode->season_number, RssFeed::ITUNES_NAMESPACE);
$item->addChild('episodeType', $episode->type, RssFeed::ITUNES_NAMESPACE);
// If episode is of type trailer, add podcast:trailer tag on channel level
if ($episode->type === 'trailer') {
$trailer = $channel->addChild('trailer', $episode->title, RssFeed::PODCAST_NAMESPACE);
$trailer->addAttribute('pubdate', $episode->published_at->format(DATE_RFC2822));
$trailer->addAttribute(
'url',
$episode->audio_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams),
);
$item->addChild('episodeType', $episode->type, $itunes_namespace);
$trailer->addAttribute('length', (string) $episode->audio->file_size);
$trailer->addAttribute('type', $episode->audio->file_mimetype);
if ($episode->season_number !== null) {
$trailer->addAttribute('season', (string) $episode->season_number);
}
}
if ($episode->transcript_file_url) {
$transcriptElement = $item->addChild(
'transcript',
null,
$podcast_namespace,
// add link to episode comments as podcast-activity format
$comments = $item->addChild('comments', null, RssFeed::PODCAST_NAMESPACE);
$comments->addAttribute('uri', url_to('episode-comments', $podcast->handle, $episode->slug));
$comments->addAttribute('contentType', 'application/podcast-activity+json');
if ($episode->getPosts()) {
$socialInteractUri = $episode->getPosts()[0]
->uri;
$socialInteractElement = $item->addChild('socialInteract', null, RssFeed::PODCAST_NAMESPACE);
$socialInteractElement->addAttribute('uri', $socialInteractUri);
$socialInteractElement->addAttribute('priority', '1');
$socialInteractElement->addAttribute('platform', 'castopod');
$socialInteractElement->addAttribute('protocol', 'activitypub');
$socialInteractElement->addAttribute(
'accountId',
"@{$podcast->actor->username}@{$podcast->actor->domain}",
);
$transcriptElement->addAttribute(
'url',
$episode->transcript_file_url,
$socialInteractElement->addAttribute(
'pubDate',
$episode->getPosts()[0]
->published_at->format(DateTime::ISO8601),
);
}
if ($episode->transcript instanceof Transcript) {
$transcriptElement = $item->addChild('transcript', null, RssFeed::PODCAST_NAMESPACE);
$transcriptElement->addAttribute('url', $episode->transcript->file_url);
$transcriptElement->addAttribute(
'type',
Mimes::guessTypeFromExtension(
pathinfo(
$episode->transcript_file_url,
PATHINFO_EXTENSION,
),
),
);
$transcriptElement->addAttribute(
'language',
$podcast->language_code,
pathinfo($episode->transcript->file_url, PATHINFO_EXTENSION),
) ?? 'text/html',
);
// Castopod only allows for captions (SubRip files)
$transcriptElement->addAttribute('rel', 'captions');
// TODO: allow for multiple languages
$transcriptElement->addAttribute('language', $podcast->language_code);
}
if ($episode->chapters_file_url) {
$chaptersElement = $item->addChild(
'chapters',
null,
$podcast_namespace,
);
$chaptersElement->addAttribute(
'url',
$episode->chapters_file_url,
);
$chaptersElement->addAttribute(
'type',
'application/json+chapters',
);
if ($episode->getChapters() instanceof Chapters) {
$chaptersElement = $item->addChild('chapters', null, RssFeed::PODCAST_NAMESPACE);
$chaptersElement->addAttribute('url', $episode->chapters->file_url);
$chaptersElement->addAttribute('type', 'application/json+chapters');
}
foreach ($episode->soundbites as $soundbite) {
$soundbiteElement = $item->addChild(
'soundbite',
empty($soundbite->label) ? null : $soundbite->label,
$podcast_namespace,
);
$soundbiteElement->addAttribute(
'start_time',
$soundbite->start_time,
);
$soundbiteElement->addAttribute(
'duration',
$soundbite->duration,
);
// TODO: differentiate video from soundbites?
$soundbiteElement = $item->addChild('soundbite', $soundbite->title, RssFeed::PODCAST_NAMESPACE);
$soundbiteElement->addAttribute('startTime', (string) $soundbite->start_time);
$soundbiteElement->addAttribute('duration', (string) round($soundbite->duration, 3));
}
foreach ($episode->persons as $episodePerson) {
$episodePersonElement = $item->addChild(
'person',
htmlspecialchars($episodePerson->person->full_name),
$podcast_namespace,
);
if (
!empty($episodePerson->person_role) &&
!empty($episodePerson->person_group)
) {
$episodePersonElement->addAttribute(
foreach ($episode->persons as $person) {
foreach ($person->roles as $role) {
$personElement = $item->addChild('person', esc($person->full_name), RssFeed::PODCAST_NAMESPACE);
$personElement->addAttribute(
'role',
htmlspecialchars(
lang(
"PersonsTaxonomy.persons.{$episodePerson->person_group}.roles.{$episodePerson->person_role}.label",
[],
'en',
),
),
esc(lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en')),
);
}
if (!empty($episodePerson->person_group)) {
$episodePersonElement->addAttribute(
$personElement->addAttribute(
'group',
htmlspecialchars(
lang(
"PersonsTaxonomy.persons.{$episodePerson->person_group}.label",
[],
'en',
),
),
);
}
$episodePersonElement->addAttribute(
'img',
$episodePerson->person->image->large_url,
);
if (!empty($episodePerson->person->information_url)) {
$episodePersonElement->addAttribute(
'href',
$episodePerson->person->information_url,
esc(lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en')),
);
$personElement->addAttribute('img', get_avatar_url($person, 'medium'));
if ($person->information_url !== null) {
$personElement->addAttribute('href', $person->information_url);
}
}
}
$episode->is_blocked &&
$item->addChild('block', 'Yes', $itunes_namespace);
if (!empty($episode->custom_rss)) {
array_to_rss(
[
'elements' => $episode->custom_rss,
],
$item,
);
if ($episode->is_blocked) {
$item->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
$plugins->rssAfterItem($episode, $item);
}
return $rss->asXML();
}
}
if (!function_exists('add_category_tag')) {
if (! function_exists('add_category_tag')) {
/**
* Adds <itunes:category> and <category> tags to node for a given category
*/
function add_category_tag(SimpleXMLElement $node, Category $category): void
function add_category_tag(RssFeed $node, Category $category): void
{
$itunes_namespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
$itunes_category = $node->addChild('category', '', $itunes_namespace);
$itunes_category->addAttribute(
$itunesCategory = $node->addChild('category', null, RssFeed::ITUNES_NAMESPACE);
$itunesCategory->addAttribute(
'text',
$category->parent !== null
$category->parent instanceof Category
? $category->parent->apple_category
: $category->apple_category,
);
if ($category->parent !== null) {
$itunes_category_child = $itunes_category->addChild(
'category',
'',
$itunes_namespace,
);
$itunes_category_child->addAttribute(
'text',
$category->apple_category,
);
if ($category->parent instanceof Category) {
$itunesCategoryChild = $itunesCategory->addChild('category', null, RssFeed::ITUNES_NAMESPACE);
$itunesCategoryChild->addAttribute('text', $category->apple_category);
$node->addChild('category', $category->parent->apple_category);
}
$node->addChild('category', $category->apple_category);
}
}
if (!function_exists('rss_to_array')) {
/**
* Converts XML to array
*
* FIXME: should be SimpleRSSElement
* @param SimpleXMLElement $xmlNode
*/
function rss_to_array(SimpleXMLElement $xmlNode): array
{
$nameSpaces = [
'',
'http://www.itunes.com/dtds/podcast-1.0.dtd',
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
];
$arrayNode = [];
$arrayNode['name'] = $xmlNode->getName();
$arrayNode['namespace'] = $xmlNode->getNamespaces(false);
foreach ($xmlNode->attributes() as $key => $value) {
$arrayNode['attributes'][$key] = (string) $value;
}
$textcontent = trim((string) $xmlNode);
if (strlen($textcontent) > 0) {
$arrayNode['content'] = $textcontent;
}
foreach ($nameSpaces as $currentNameSpace) {
foreach ($xmlNode->children($currentNameSpace) as $childXmlNode) {
$arrayNode['elements'][] = rss_to_array($childXmlNode);
}
}
return $arrayNode;
}
}
if (!function_exists('array_to_rss')) {
/**
* Inserts array (converted to XML node) in XML node
*
* @param SimpleRSSElement $xmlNode The XML parent node where this arrayNode should be attached
*/
function array_to_rss(array $arrayNode, SimpleRSSElement &$xmlNode)
{
if (array_key_exists('elements', $arrayNode)) {
foreach ($arrayNode['elements'] as $childArrayNode) {
$childXmlNode = $xmlNode->addChild(
$childArrayNode['name'],
$childArrayNode['content'] ?? null,
empty($childArrayNode['namespace'])
? null
: current($childArrayNode['namespace']),
);
if (array_key_exists('attributes', $childArrayNode)) {
foreach (
$childArrayNode['attributes']
as $attributeKey => $attributeValue
) {
$childXmlNode->addAttribute(
$attributeKey,
$attributeValue,
);
}
}
array_to_rss($childArrayNode, $childXmlNode);
}
}
return $xmlNode;
$node->addChild('category', $category->apple_category);
}
}
<?php
declare(strict_types=1);
use App\Entities\Actor;
use App\Entities\Episode;
use App\Entities\EpisodeComment;
use App\Entities\Page;
use App\Entities\Podcast;
use App\Entities\Post;
use App\Libraries\HtmlHead;
use Melbahja\Seo\Schema;
use Melbahja\Seo\Schema\Thing;
use Modules\Fediverse\Entities\PreviewCard;
/**
* @copyright 2024 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (! function_exists('set_podcast_metatags')) {
function set_podcast_metatags(Podcast $podcast, string $page): void
{
$category = '';
if ($podcast->category->parent_id !== null) {
$category .= $podcast->category->parent->apple_category . ' › ';
}
$category .= $podcast->category->apple_category;
$schema = new Schema(
new Thing('PodcastSeries', [
'name' => $podcast->title,
'headline' => $podcast->title,
'url' => current_url(),
'sameAs' => $podcast->link,
'identifier' => $podcast->guid,
'image' => $podcast->cover->feed_url,
'description' => $podcast->description,
'webFeed' => $podcast->feed_url,
'accessMode' => 'auditory',
'author' => $podcast->owner_name,
'creator' => $podcast->owner_name,
'publisher' => $podcast->publisher,
'inLanguage' => $podcast->language_code,
'genre' => $category,
]),
);
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(sprintf('%s (@%s) • %s', $podcast->title, $podcast->handle, lang('Podcast.' . $page)))
->description(esc($podcast->description))
->image((string) $podcast->cover->og_url)
->canonical((string) current_url())
->og('image:width', (string) config('Images')->podcastCoverSizes['og']['width'])
->og('image:height', (string) config('Images')->podcastCoverSizes['og']['height'])
->og('locale', $podcast->language_code)
->og('site_name', esc(service('settings')->get('App.siteName')))
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('podcast-activity', esc($podcast->handle)),
])->appendRawContent('<link type="application/rss+xml" rel="alternate" title="' . esc(
$podcast->title,
) . '" href="' . $podcast->feed_url . '" />' . $schema);
}
}
if (! function_exists('set_episode_metatags')) {
function set_episode_metatags(Episode $episode): void
{
$schema = new Schema(
new Thing('PodcastEpisode', [
'url' => url_to('episode', esc($episode->podcast->handle), $episode->slug),
'name' => $episode->title,
'image' => $episode->cover->feed_url,
'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ATOM),
'timeRequired' => iso8601_duration($episode->audio->duration),
'duration' => iso8601_duration($episode->audio->duration),
'associatedMedia' => new Thing('MediaObject', [
'contentUrl' => $episode->audio_url,
]),
'partOfSeries' => new Thing('PodcastSeries', [
'name' => $episode->podcast->title,
'url' => $episode->podcast->link,
]),
]),
);
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title($episode->title)
->description(esc($episode->description))
->image((string) $episode->cover->og_url, 'player')
->canonical($episode->link)
->og('site_name', esc(service('settings')->get('App.siteName')))
->og('image:width', (string) config('Images')->podcastCoverSizes['og']['width'])
->og('image:height', (string) config('Images')->podcastCoverSizes['og']['height'])
->og('locale', $episode->podcast->language_code)
->og('audio', $episode->audio_opengraph_url)
->og('audio:type', $episode->audio->file_mimetype)
->meta('article:published_time', $episode->published_at->format(DATE_ATOM))
->meta('article:modified_time', $episode->updated_at->format(DATE_ATOM))
->twitter('audio:partner', $episode->podcast->publisher ?? '')
->twitter('audio:artist_name', esc($episode->podcast->owner_name))
->twitter('player', $episode->getEmbedUrl('light'))
->twitter('player:width', (string) config('Embed')->width)
->twitter('player:height', (string) config('Embed')->height)
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => $episode->link,
])
->appendRawContent('<link rel="alternate" type="application/json+oembed" href="' . base_url(
route_to('episode-oembed-json', $episode->podcast->handle, $episode->slug),
) . '" title="' . esc(
$episode->title,
) . ' oEmbed json" />' . '<link rel="alternate" type="text/xml+oembed" href="' . base_url(
route_to('episode-oembed-xml', $episode->podcast->handle, $episode->slug),
) . '" title="' . esc($episode->title) . ' oEmbed xml" />' . $schema);
}
}
if (! function_exists('set_post_metatags')) {
function set_post_metatags(Post $post): void
{
$socialMediaPosting = new Thing('SocialMediaPosting', [
'@id' => url_to('post', esc($post->actor->username), $post->id),
'datePublished' => $post->published_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $post->actor->display_name,
'url' => $post->actor->uri,
]),
'text' => $post->message,
]);
if ($post->episode_id !== null) {
$socialMediaPosting->__set('sharedContent', new Thing('Audio', [
'headline' => $post->episode->title,
'url' => $post->episode->link,
'author' => new Thing('Person', [
'name' => $post->episode->podcast->owner_name,
]),
]));
} elseif ($post->preview_card instanceof PreviewCard) {
$socialMediaPosting->__set('sharedContent', new Thing('WebPage', [
'headline' => $post->preview_card->title,
'url' => $post->preview_card->url,
'author' => new Thing('Person', [
'name' => $post->preview_card->author_name,
]),
]));
}
$schema = new Schema($socialMediaPosting);
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Post.title', [
'actorDisplayName' => $post->actor->display_name,
]))
->description($post->message)
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')))
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('post', esc($post->actor->username), $post->id),
])->appendRawContent((string) $schema);
}
}
if (! function_exists('set_episode_comment_metatags')) {
function set_episode_comment_metatags(EpisodeComment $episodeComment): void
{
$schema = new Schema(new Thing('SocialMediaPosting', [
'@id' => url_to(
'episode-comment',
esc($episodeComment->actor->username),
$episodeComment->episode->slug,
$episodeComment->id,
),
'datePublished' => $episodeComment->created_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $episodeComment->actor->display_name,
'url' => $episodeComment->actor->uri,
]),
'text' => $episodeComment->message,
'upvoteCount' => $episodeComment->likes_count,
]));
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Comment.title', [
'actorDisplayName' => $episodeComment->actor->display_name,
'episodeTitle' => $episodeComment->episode->title,
]))
->description($episodeComment->message)
->image($episodeComment->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')))
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to(
'episode-comment',
$episodeComment->actor->username,
$episodeComment->episode->slug,
$episodeComment->id,
),
])->appendRawContent((string) $schema);
}
}
if (! function_exists('set_follow_metatags')) {
function set_follow_metatags(Actor $actor): void
{
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Podcast.followTitle', [
'actorDisplayName' => $actor->display_name,
]))
->description($actor->summary)
->image($actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
}
}
if (! function_exists('set_remote_actions_metatags')) {
function set_remote_actions_metatags(Post $post, string $action): void
{
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Fediverse.' . $action . '.title', [
'actorDisplayName' => $post->actor->display_name,
],))
->description($post->message)
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
}
}
if (! function_exists('set_home_metatags')) {
function set_home_metatags(): void
{
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(service('settings')->get('App.siteName'))
->description(esc(service('settings')->get('App.siteDescription')))
->image(get_site_icon_url('512'))
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
}
}
if (! function_exists('set_page_metatags')) {
function set_page_metatags(Page $page): void
{
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(
$page->title . service('settings')->get('App.siteTitleSeparator') . service(
'settings',
)->get('App.siteName'),
)
->description(esc(service('settings')->get('App.siteDescription')))
->image(get_site_icon_url('512'))
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
}
}
if (! function_exists('iso8601_duration')) {
// From https://stackoverflow.com/a/40761380
function iso8601_duration(float $seconds): string
{
$days = floor($seconds / 86400);
$seconds = (int) $seconds % 86400;
$hours = floor($seconds / 3600);
$seconds %= 3600;
$minutes = floor($seconds / 60);
$seconds %= 60;
return sprintf('P%dDT%dH%dM%dS', $days, $hours, $minutes, $seconds);
}
}
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (!function_exists('icon')) {
/**
* Returns the inline svg icon
*
* @param string $name name of the icon file without the .svg extension
* @param string $class to be added to the svg string
* @return string svg contents
*/
function icon(string $name, string $class = ''): string
{
$svg_contents = file_get_contents('assets/icons/' . $name . '.svg');
if ($class !== '') {
$svg_contents = str_replace(
'<svg',
'<svg class="' . $class . '"',
$svg_contents,
);
}
return $svg_contents;
}
}
if (!function_exists('svg')) {
if (! function_exists('svg')) {
/**
* Returns the inline svg image
*
* @param string $name name of the image file without the .svg extension
* @param string $class to be added to the svg string
* @param string|null $class to be added to the svg string
* @return string svg contents
*/
function svg(string $name, ?string $class = null): string
{
$svg_contents = file_get_contents('assets/images/' . $name . '.svg');
$svgContents = file_get_contents('assets/images/' . $name . '.svg');
if ($class) {
$svg_contents = str_replace(
'<svg',
'<svg class="' . $class . '"',
$svg_contents,
);
return str_replace('<svg', '<svg class="' . $class . '"', $svgContents);
}
return $svg_contents;
return $svgContents;
}
}
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use CodeIgniter\HTTP\URI;
if (!function_exists('host_url')) {
if (! function_exists('host_url')) {
/**
* Return the host URL to use in views
*/
function host_url(): ?string
{
if (isset($_SERVER['HTTP_HOST'])) {
$superglobals = service('superglobals');
if ($superglobals->server('HTTP_HOST') !== null) {
$protocol =
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
$_SERVER['SERVER_PORT'] == 443
($superglobals->server('HTTPS') !== null && $superglobals->server('HTTPS') !== 'off') ||
(int) $superglobals->server('SERVER_PORT') === 443
? 'https://'
: 'http://';
return $protocol . $_SERVER['HTTP_HOST'] . '/';
return $protocol . $superglobals->server('HTTP_HOST') . '/';
}
return null;
}
}
if (!function_exists('current_season_url')) {
//--------------------------------------------------------------------
/**
* Return the host URL to use in views
*/
if (! function_exists('current_domain')) {
/**
* Return the podcast URL with season number to use in views
* Returns instance's domain name
*/
function current_season_url(): string
function current_domain(): string
{
$season_query_string = '';
if (isset($_GET['season'])) {
$season_query_string = '?season=' . $_GET['season'];
} elseif (isset($_GET['year'])) {
$season_query_string = '?year=' . $_GET['year'];
}
return current_url() . $season_query_string;
/** @var URI $uri */
$uri = current_url(true);
return $uri->getHost() . ($uri->getPort() ? ':' . $uri->getPort() : '');
}
}
//--------------------------------------------------------------------
if (!function_exists('extract_params_from_episode_uri')) {
if (! function_exists('extract_params_from_episode_uri')) {
/**
* Returns podcast name and episode slug from episode string uri
* Returns podcast name and episode slug from episode string
*
* @return array<string, string>|null
*/
function extract_params_from_episode_uri(URI $episodeUri): ?array
{
preg_match(
'~@(?P<podcastName>[a-zA-Z0-9\_]{1,32})\/episodes\/(?P<episodeSlug>[a-zA-Z0-9\-]{1,191})~',
'~@(?P<podcastHandle>[a-zA-Z0-9\_]{1,32})\/episodes\/(?P<episodeSlug>[a-zA-Z0-9\-]{1,128})~',
$episodeUri->getPath(),
$matches,
);
......@@ -62,15 +69,15 @@ if (!function_exists('extract_params_from_episode_uri')) {
}
if (
!array_key_exists('podcastName', $matches) ||
!array_key_exists('episodeSlug', $matches)
! array_key_exists('podcastHandle', $matches) ||
! array_key_exists('episodeSlug', $matches)
) {
return null;
}
return [
'podcastName' => $matches['podcastName'],
'episodeSlug' => $matches['episodeSlug'],
'podcastHandle' => $matches['podcastHandle'],
'episodeSlug' => $matches['episodeSlug'],
];
}
}
+ en/***
+ fr/***
+ pl/***
+ de/***
+ pt-br/***
+ nn-no/***
+ es/***
+ zh-hans/***
+ ca/***
+ br/***
+ sr-latn/***
- **
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'title' => "تعليق {actorDisplayName} على {episodeTitle}",
'back_to_comments' => 'العودة إلى التعليقات',
'form' => [
'episode_message_placeholder' => 'أكتب تعليقاً…',
'reply_to_placeholder' => 'رد على @{actorUsername}',
'submit' => 'ارسل',
'submit_reply' => 'رد',
],
'likes' => '{numberOfLikes, plural,
one {# like}
other {# likes}
}',
'replies' => '{numberOfReplies, plural,
one {# reply}
other {# replies}
}',
'like' => 'Like',
'reply' => 'رد',
'view_replies' => 'View replies ({numberOfReplies})',
'block_actor' => 'Block user @{actorUsername}',
'block_domain' => 'Block domain @{actorDomain}',
'delete' => 'احذف التعليق',
];
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'yes' => 'نعم',
'no' => 'لا',
'cancel' => 'ألغِ',
'optional' => 'اختياري',
'close' => 'أغلق',
'home' => 'الرئيسية',
'explicit' => 'Explicit',
'powered_by' => 'Powered by {castopod}',
'go_back' => 'العودة',
'play_episode_button' => [
'play' => 'تشغيل',
'playing' => 'Playing',
],
'read_more' => 'اقرأ المزيد',
'read_less' => 'Read less',
'see_more' => 'الاطّلاع على المزيد',
'see_less' => 'See less',
'legal_notice' => 'Legal notice',
];
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'season' => 'الموسم {seasonNumber}',
'season_abbr' => 'م{seasonNumber}',
'number' => 'الحلقة {episodeNumber}',
'number_abbr' => 'الحلقة {episodeNumber}',
'season_episode' => 'الموسم {seasonNumber} الحلقة {episodeNumber}',
'season_episode_abbr' => 'م{seasonNumber}:ح{episodeNumber}',
'persons' => '{personsCount, plural,
one {# person}
other {# persons}
}',
'persons_list' => 'أشخاص',
'back_to_episodes' => 'العودة إلى حلقات {podcast}',
'comments' => 'التعليقات',
'activity' => 'النشاط',
'chapters' => 'Chapters',
'transcript' => 'Transcript',
'description' => 'وصف الحلقة',
'number_of_comments' => '{numberOfComments, plural,
one {# comment}
other {# comments}
}',
'all_podcast_episodes' => 'كافة حلقات البودكاست',
'back_to_podcast' => 'العودة إلى البودكاست',
'preview' => [
'title' => 'Preview',
'not_published' => 'Not published',
'text' => '{publication_status, select,
published {This episode is not yet published.}
scheduled {This episode is scheduled for publication on {publication_date}.}
with_podcast {This episode will be published at the same time as the podcast.}
other {This episode is not yet published.}
}',
'publish' => 'Publish',
'publish_edit' => 'Edit publication',
],
'no_chapters' => 'No chapters are available for this episode.',
'download_transcript' => 'Download transcript ({extension})',
'no_transcript' => 'No transcript available for this episode.',
];
<?php
declare(strict_types=1);
/**
* @copyright 2021 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'your_handle' => 'Your handle',
'your_handle_hint' => 'Enter the @username@domain you want to act from.',
'follow' => [
'label' => 'تابِع',
'title' => 'تابع {actorDisplayName}',
'subtitle' => 'إنك بصدد متابعة:',
'accountNotFound' => 'لا يمكن العثور على الحساب.',
'remoteFollowNotAllowed' => 'Seems like the account server does not allow remote follows…',
'submit' => 'اتمم المتابعة',
],
'favourite' => [
'title' => "Favourite {actorDisplayName}'s post",
'subtitle' => 'You are going to favourite:',
'submit' => 'Proceed to favourite',
],
'reblog' => [
'title' => "Share {actorDisplayName}'s post",
'subtitle' => 'You are going to share:',
'submit' => 'اتمم المشاركة',
],
'reply' => [
'title' => "Reply to {actorDisplayName}'s post",
'subtitle' => 'You are going to reply to:',
'submit' => 'Proceed to reply',
],
];
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'all_podcasts' => 'كافة البودكاستات',
'sort_by' => 'ترتيب حسب',
'sort_options' => [
'activity' => 'آخر نشاط',
'created_desc' => 'الأحدث أولًا',
'created_asc' => 'الأقدم أولاً',
],
'no_podcast' => 'لا يوجد أي بودكاست',
];
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'back_to_home' => 'العودة إلى الرئيسية',
'map' => [
'title' => 'الخريطة',
'description' => 'Discover podcast episodes on {siteName} that are placed on a map! Travel through the map and listen to episodes that talk about specific locations.',
],
];
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'feed' => 'RSS Podcast feed',
'season' => 'الموسم {seasonNumber}',
'list_of_episodes_year' => 'حلَقات {year} ({episodeCount})',
'list_of_episodes_season' =>
'الموسم {seasonNumber} الحلقات ({episodeCount})',
'no_episode' => 'لم يتم العثور على أية حلقة!',
'follow' => 'متابعة',
'followTitle' => 'تابع {actorDisplayName} على الفديفرس!',
'followers' => '{numberOfFollowers, plural,
one {# follower}
other {# followers}
}',
'posts' => '{numberOfPosts, plural,
one {# post}
other {# posts}
}',
'links' => 'Links',
'activity' => 'النشاط',
'episodes' => 'الحلقات',
'episodes_title' => 'حلقات {podcastTitle}',
'about' => 'عن',
'stats' => [
'title' => 'الإحصائيات',
'number_of_seasons' => '{0, plural,
one {# season}
other {# seasons}
}',
'number_of_episodes' => '{0, plural,
one {# episode}
other {# episodes}
}',
'first_published_at' => 'First episode published on {0, date, medium}',
],
'sponsor' => 'الراعي',
'funding_links' => 'Funding links for {podcastTitle}',
'find_on' => 'Find {podcastTitle} on',
'listen_on' => 'Listen on',
'persons' => '{personsCount, plural,
one {# person}
other {# persons}
}',
'persons_list' => 'أشخاص',
'castopod_website' => 'Castopod (website)',
];
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'title' => "{actorDisplayName}'s post",
'back_to_actor_posts' => 'العودة إلى منشورات {actor}',
'actor_shared' => 'شاركه {actor}',
'reply_to' => 'رد على @{actorUsername}',
'form' => [
'message_placeholder' => 'اكتب رسالة…',
'episode_message_placeholder' => 'Write a message for the episode…',
'episode_url_placeholder' => 'الوصلة الشبكية للبودكاست',
'reply_to_placeholder' => 'رد على @{actorUsername}',
'submit' => 'ارسل',
'submit_reply' => 'رد',
],
'favourites' => '{numberOfFavourites, plural,
one {# favourite}
other {# favourites}
}',
'reblogs' => '{numberOfReblogs, plural,
one {# share}
other {# shares}
}',
'replies' => '{numberOfReplies, plural,
one {# reply}
other {# replies}
}',
'expand' => 'Expand post',
'block_actor' => 'احجب المستخدم @{actorUsername}',
'block_domain' => 'احجب النطاق @{actorDomain}',
'delete' => 'احذف المنشور',
];
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'title' => "Evezhiadenn {actorDisplayName} evit {episodeTitle}",
'back_to_comments' => 'Distreiñ d\'an evezhiadennoù',
'form' => [
'episode_message_placeholder' => 'Skrivañ un evezhiadenn…',
'reply_to_placeholder' => 'Respont da @{actorUsername}',
'submit' => 'Kas',
'submit_reply' => 'Respont',
],
'likes' => '{numberOfLikes, plural,
one {# muiañ-karet}
two {# vuiañ-karet}
few {# muiañ-karet}
many {# muiañ-karet}
other {# muiañ-karet}
}',
'replies' => '{numberOfReplies, plural,
one {# respont}
two {# respont}
few {# respont}
many {# respont}
other {# respont}
}',
'like' => 'Muiañ-karet',
'reply' => 'Respont',
'view_replies' => 'Gwelet an evezhiadennoù ({numberOfReplies})',
'block_actor' => 'Stankañ an implijer·ez @{actorUsername}',
'block_domain' => 'Stankañ @{actorDomain}',
'delete' => 'Dilemel an evezhiadenn',
];