feat: add premium podcasts to manage subscriptions for premium episodes

closes #193
This commit is contained in:
Yassine Doghri 2022-09-28 15:02:09 +00:00
parent b6114d3d93
commit 3234500e2d
101 changed files with 2572 additions and 110 deletions

View File

@ -51,6 +51,7 @@ class Autoload extends AutoloadConfig
'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse/',
'Modules\WebSub' => ROOTPATH . 'modules/WebSub/',
'Modules\Api\Rest\V1' => ROOTPATH . 'modules/Api/Rest/V1',
'Modules\PremiumPodcasts' => ROOTPATH . 'modules/PremiumPodcasts/',
'Config' => APPPATH . 'Config/',
'ViewComponents' => APPPATH . 'Libraries/ViewComponents/',
'ViewThemes' => APPPATH . 'Libraries/ViewThemes/',

View File

@ -14,6 +14,7 @@ use Modules\Api\Rest\V1\Filters\ApiFilter;
use Modules\Auth\Filters\PermissionFilter;
use Modules\Fediverse\Filters\AllowCorsFilter;
use Modules\Fediverse\Filters\FediverseFilter;
use Modules\PremiumPodcasts\Filters\PodcastUnlockFilter;
use Myth\Auth\Filters\LoginFilter;
use Myth\Auth\Filters\RoleFilter;
@ -36,6 +37,7 @@ class Filters extends BaseConfig
'fediverse' => FediverseFilter::class,
'allow-cors' => AllowCorsFilter::class,
'rest-api' => ApiFilter::class,
'podcast-unlock' => PodcastUnlockFilter::class,
];
/**
@ -87,6 +89,9 @@ class Filters extends BaseConfig
'login' => [
'before' => [config('Admin')->gateway . '*', config('Analytics')->gateway . '*'],
],
'podcast-unlock' => [
'before' => ['*@*/episodes/*'],
],
];
}
}

View File

@ -28,7 +28,7 @@ abstract class BaseController extends Controller
ResponseInterface $response,
LoggerInterface $logger
): void {
$this->helpers = array_merge($this->helpers, ['auth', 'svg', 'components', 'misc', 'seo']);
$this->helpers = array_merge($this->helpers, ['auth', 'svg', 'components', 'misc', 'seo', 'premium_podcasts']);
// Do Not Edit This Line
parent::initController($request, $response, $logger);

View File

@ -10,19 +10,21 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Entities\Podcast;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\ResponseInterface;
use Exception;
use Modules\PremiumPodcasts\Models\SubscriptionModel;
use Opawg\UserAgentsPhp\UserAgentsRSS;
class FeedController extends Controller
{
public function index(string $podcastHandle): ResponseInterface
{
helper('rss');
helper(['rss', 'premium_podcasts']);
$podcast = (new PodcastModel())->where('handle', $podcastHandle)
->first();
@ -43,11 +45,24 @@ class FeedController extends Controller
$serviceSlug = $service['slug'];
}
$cacheName =
"podcast#{$podcast->id}_feed" . ($service ? "_{$serviceSlug}" : '');
$subscription = null;
$token = $this->request->getGet('token');
if ($token) {
$subscription = (new SubscriptionModel())->validateSubscription($podcastHandle, $token);
}
$cacheName = implode(
'_',
array_filter([
"podcast#{$podcast->id}",
'feed',
$service ? $serviceSlug : null,
$subscription !== null ? 'unlocked' : null,
]),
);
if (! ($found = cache($cacheName))) {
$found = get_rss_feed($podcast, $serviceSlug);
$found = get_rss_feed($podcast, $serviceSlug, $subscription, $token);
// The page cache is set to expire after next episode publication or a decade by default so it is deleted manually upon podcast update
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(

View File

@ -40,7 +40,7 @@ class PostController extends FediversePostController
/**
* @var string[]
*/
protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo'];
protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo', 'premium_podcasts'];
public function _remap(string $method, string ...$params): mixed
{

View File

@ -169,6 +169,11 @@ class AddPodcasts extends Migration
'constraint' => 512,
'null' => true,
],
'is_premium_by_default' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
],
'created_by' => [
'type' => 'INT',
'unsigned' => true,

View File

@ -129,6 +129,11 @@ class AddEpisodes extends Migration
'unsigned' => true,
'default' => 0,
],
'is_premium' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
],
'created_by' => [
'type' => 'INT',
'unsigned' => true,

View File

@ -154,6 +154,12 @@ class AuthSeeder extends Seeder
'description' => 'Edit a podcast',
'has_permission' => ['podcast_admin'],
],
[
'name' => 'manage_subscriptions',
'description' =>
'Add / edit / remove podcast subscriptions',
'has_permission' => ['podcast_admin'],
],
[
'name' => 'manage_contributors',
'description' =>

View File

@ -73,16 +73,17 @@ use RuntimeException;
* @property int $posts_count
* @property int $comments_count
* @property EpisodeComment[]|null $comments
* @property bool $is_premium
* @property int $created_by
* @property int $updated_by
* @property string $publication_status;
* @property Time|null $published_at;
* @property Time $created_at;
* @property Time $updated_at;
* @property string $publication_status
* @property Time|null $published_at
* @property Time $created_at
* @property Time $updated_at
*
* @property Person[] $persons;
* @property Soundbite[] $soundbites;
* @property string $embed_url;
* @property Person[] $persons
* @property Soundbite[] $soundbites
* @property string $embed_url
*/
class Episode extends Entity
{
@ -168,6 +169,7 @@ class Episode extends Entity
'is_published_on_hubs' => 'boolean',
'posts_count' => 'integer',
'comments_count' => 'integer',
'is_premium' => 'boolean',
'created_by' => 'integer',
'updated_by' => 'integer',
];
@ -233,7 +235,7 @@ class Episode extends Entity
(new MediaModel('audio'))->updateMedia($this->getAudio());
} else {
$audio = new Audio([
'file_name' => $this->attributes['slug'],
'file_name' => pathinfo($file->getRandomName(), PATHINFO_FILENAME),
'file_directory' => 'podcasts/' . $this->getPodcast()->handle,
'language_code' => $this->getPodcast()
->language_code,
@ -337,16 +339,20 @@ class Episode extends Entity
{
helper('analytics');
// remove 'podcasts/' from audio file path
$strippedAudioPath = substr($this->getAudio()->file_path, 9);
return generate_episode_analytics_url(
$this->podcast_id,
$this->id,
$strippedAudioPath,
$this->audio->duration,
$this->audio->file_size,
$this->audio->header_size,
$this->getPodcast()
->handle,
$this->attributes['slug'],
$this->getAudio()
->file_extension,
$this->getAudio()
->duration,
$this->getAudio()
->file_size,
$this->getAudio()
->header_size,
$this->published_at,
);
}

View File

@ -114,4 +114,27 @@ class BaseMedia extends Entity
$mediaModel = new MediaModel();
return $mediaModel->delete($this->id);
}
public function rename(): bool
{
$newFilePath = $this->file_directory . '/' . (new File(''))->getRandomName() . '.' . $this->file_extension;
$db = db_connect();
$db->transStart();
if (! (new MediaModel())->update($this->id, [
'file_path' => $newFilePath,
])) {
return false;
}
if (! rename(media_path($this->file_path), media_path($newFilePath))) {
$db->transRollback();
return false;
}
$db->transComplete();
return true;
}
}

View File

@ -30,6 +30,8 @@ use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
use League\CommonMark\MarkdownConverter;
use Modules\Auth\Entities\User;
use Modules\PremiumPodcasts\Entities\Subscription;
use Modules\PremiumPodcasts\Models\SubscriptionModel;
use RuntimeException;
/**
@ -79,14 +81,17 @@ use RuntimeException;
* @property string|null $partner_image_url
* @property int $created_by
* @property int $updated_by
* @property string $publication_status;
* @property Time|null $published_at;
* @property Time $created_at;
* @property Time $updated_at;
* @property string $publication_status
* @property bool $is_premium_by_default
* @property bool $is_premium
* @property Time|null $published_at
* @property Time $created_at
* @property Time $updated_at
*
* @property Episode[] $episodes
* @property Person[] $persons
* @property User[] $contributors
* @property Subscription[] $subscriptions
* @property Platform[] $podcasting_platforms
* @property Platform[] $social_platforms
* @property Platform[] $funding_platforms
@ -130,6 +135,11 @@ class Podcast extends Entity
*/
protected ?array $contributors = null;
/**
* @var Subscription[]|null
*/
protected ?array $subscriptions = null;
/**
* @var Platform[]|null
*/
@ -182,6 +192,7 @@ class Podcast extends Entity
'is_blocked' => 'boolean',
'is_completed' => 'boolean',
'is_locked' => 'boolean',
'is_premium_by_default' => 'boolean',
'imported_feed_url' => '?string',
'new_feed_url' => '?string',
'location_name' => '?string',
@ -380,6 +391,24 @@ class Podcast extends Entity
return $this->category;
}
/**
* Returns all podcast subscriptions
*
* @return Subscription[]
*/
public function getSubscriptions(): array
{
if ($this->id === null) {
throw new RuntimeException('Podcasts must be created before getting subscriptions.');
}
if ($this->subscriptions === null) {
$this->subscriptions = (new SubscriptionModel())->getPodcastSubscriptions($this->id);
}
return $this->subscriptions;
}
/**
* Returns all podcast contributors
*
@ -652,4 +681,10 @@ class Podcast extends Entity
return $this;
}
public function getIsPremium(): bool
{
// podcast is premium if at least one of its episodes is set as premium
return (new EpisodeModel())->doesPodcastHavePremiumEpisodes($this->id);
}
}

View File

@ -18,7 +18,7 @@ if (! function_exists('save_media')) {
/**
* Saves a file to the corresponding podcast folder in `public/media`
*/
function save_media(File | UploadedFile $file, string $folder = '', string $filename = ''): string
function save_media(File | UploadedFile $file, string $folder = '', string $filename = null): string
{
if (($extension = $file->getExtension()) !== '') {
$filename = $filename . '.' . $extension;

View File

@ -13,6 +13,7 @@ use App\Entities\Podcast;
use App\Libraries\SimpleRSSElement;
use CodeIgniter\I18n\Time;
use Config\Mimes;
use Modules\PremiumPodcasts\Entities\Subscription;
if (! function_exists('get_rss_feed')) {
/**
@ -21,8 +22,12 @@ if (! function_exists('get_rss_feed')) {
* @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 = ''): string
{
function get_rss_feed(
Podcast $podcast,
string $serviceSlug = '',
Subscription $subscription = null,
string $token = null
): string {
$episodes = $podcast->episodes;
$itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
@ -267,16 +272,22 @@ if (! function_exists('get_rss_feed')) {
}
foreach ($episodes as $episode) {
if ($episode->is_premium && $subscription === null) {
continue;
}
$item = $channel->addChild('item');
$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_analytics_url .
($serviceSlug === ''
? ''
: '?_from=' . urlencode($serviceSlug)),
$episode->audio_analytics_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams),
);
$enclosure->addAttribute('length', (string) $episode->audio->file_size);
$enclosure->addAttribute('type', $episode->audio->file_mimetype);

View File

@ -31,6 +31,22 @@ if (! function_exists('host_url')) {
//--------------------------------------------------------------------
/**
* Return the host URL to use in views
*/
if (! function_exists('current_domain')) {
/**
* Returns instance's domain name
*/
function current_domain(): string
{
$uri = current_url(true);
return $uri->getHost() . ($uri->getPort() ? ':' . $uri->getPort() : '');
}
}
//--------------------------------------------------------------------
if (! function_exists('extract_params_from_episode_uri')) {
/**
* Returns podcast name and episode slug from episode string

View File

@ -85,6 +85,7 @@ class EpisodeModel extends Model
'is_published_on_hubs',
'posts_count',
'comments_count',
'is_premium',
'published_at',
'created_by',
'updated_by',
@ -413,6 +414,15 @@ class EpisodeModel extends Model
return $data;
}
public function doesPodcastHavePremiumEpisodes(int $podcastId): bool
{
return $this->builder()
->where([
'podcast_id' => $podcastId,
'is_premium' => true,
])->countAllResults() > 0;
}
/**
* @param mixed[] $data
*

View File

@ -64,6 +64,7 @@ class PodcastModel extends Model
'partner_id',
'partner_link_url',
'partner_image_url',
'is_premium_by_default',
'published_at',
'created_by',
'updated_by',

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M5.373 4.51A9.962 9.962 0 0 1 12 2c5.523 0 10 4.477 10 10a9.954 9.954 0 0 1-1.793 5.715L17.5 12H20A8 8 0 0 0 6.274 6.413l-.9-1.902zm13.254 14.98A9.962 9.962 0 0 1 12 22C6.477 22 2 17.523 2 12c0-2.125.663-4.095 1.793-5.715L6.5 12H4a8 8 0 0 0 13.726 5.587l.9 1.902zM8.5 14H14a.5.5 0 1 0 0-1h-4a2.5 2.5 0 1 1 0-5h1V7h2v1h2.5v2H10a.5.5 0 1 0 0 1h4a2.5 2.5 0 1 1 0 5h-1v1h-2v-1H8.5v-2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 531 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M7 10h13a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V11a1 1 0 0 1 1-1h1V9a7 7 0 0 1 13.262-3.131l-1.789.894A5 5 0 0 0 7 9v1zm3 5v2h4v-2h-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M18 8h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h2V7a6 6 0 1 1 12 0v1zm-7 7.732V18h2v-2.268a2 2 0 1 0-2 0zM16 8V7a4 4 0 1 0-8 0v1h8z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@ -1,3 +1,5 @@
import Dropdown from "./modules/Dropdown";
import Tooltip from "./modules/Tooltip";
Dropdown();
Tooltip();

View File

@ -131,8 +131,7 @@ export class PlayEpisodeButton extends LitElement {
private _showPlayer(): void {
this._castopodAudioPlayer.style.display = "";
document.body.classList.add("pb-[105px]");
document.body.classList.add("sm:pb-[52px]");
document.body.classList.add("pb-[105px]", "sm:pb-[52px]");
}
private _flushLastPlayButton(playingEpisodeButton: PlayEpisodeButton): void {

View File

@ -28,6 +28,10 @@
border-radius: max(0px, min(1rem, calc((100vw - 1rem - 100%) * 9999)));
}
.rounded-conditional-full {
border-radius: max(0px, min(9999px, calc((100vw - 1rem - 100%) * 9999)));
}
.backdrop-gradient {
background-image: linear-gradient(
180deg,

View File

@ -28,7 +28,7 @@ class Button extends Component
public function render(): string
{
$baseClass =
'flex-shrink-0 inline-flex items-center justify-center font-semibold shadow-xs rounded-full focus:ring-accent';
'gap-x-2 flex-shrink-0 inline-flex items-center justify-center font-semibold shadow-xs rounded-full focus:ring-accent';
$variantClass = [
'default' => 'text-black bg-gray-300 hover:bg-gray-400',
@ -84,14 +84,14 @@ class Button extends Component
if ($this->iconLeft !== '') {
$this->slot = (new Icon([
'glyph' => $this->iconLeft,
'class' => 'mr-2 opacity-75' . ' ' . $iconSize[$this->size],
'class' => 'opacity-75' . ' ' . $iconSize[$this->size],
]))->render() . $this->slot;
}
if ($this->iconRight !== '') {
$this->slot .= (new Icon([
'glyph' => $this->iconRight,
'class' => 'ml-2 opacity-75' . ' ' . $iconSize[$this->size],
'class' => 'opacity-75' . ' ' . $iconSize[$this->size],
]))->render();
}

View File

@ -53,7 +53,7 @@ class DropdownMenu extends Component
return <<<HTML
<nav id="{$this->id}"
class="absolute z-50 flex flex-col py-2 rounded-lg whitespace-nowrap text-skin-base border-contrast bg-elevated border-3"
class="absolute flex flex-col py-2 rounded-lg z-60 whitespace-nowrap text-skin-base border-contrast bg-elevated border-3"
aria-labelledby="{$this->labelledby}"
data-dropdown="menu"
data-dropdown-placement="{$this->placement}"

View File

@ -19,10 +19,9 @@ class Icon extends Component
return '□';
}
if ($this->attributes['class'] !== '') {
return str_replace('<svg', '<svg class="' . $this->attributes['class'] . '"', $svgContents);
}
unset($this->attributes['glyph']);
$attributes = stringify_attributes($this->attributes);
return $svgContents;
return str_replace('<svg', '<svg ' . $attributes, $svgContents);
}
}

View File

@ -193,6 +193,7 @@ class EpisodeController extends BaseController
'type' => $this->request->getPost('type'),
'is_blocked' => $this->request->getPost('block') === 'yes',
'custom_rss_string' => $this->request->getPost('custom_rss'),
'is_premium' => $this->request->getPost('premium') === 'yes',
'created_by' => user_id(),
'updated_by' => user_id(),
'published_at' => null,
@ -308,6 +309,7 @@ class EpisodeController extends BaseController
$this->episode->type = $this->request->getPost('type');
$this->episode->is_blocked = $this->request->getPost('block') === 'yes';
$this->episode->custom_rss_string = $this->request->getPost('custom_rss');
$this->episode->is_premium = $this->request->getPost('premium') === 'yes';
$this->episode->updated_by = (int) user_id();
$this->episode->setAudio($this->request->getFile('audio_file'));

View File

@ -238,6 +238,7 @@ class PodcastController extends BaseController
'is_blocked' => $this->request->getPost('block') === 'yes',
'is_completed' => $this->request->getPost('complete') === 'yes',
'is_locked' => $this->request->getPost('lock') === 'yes',
'is_premium_by_default' => $this->request->getPost('premium_by_default') === 'yes',
'created_by' => user_id(),
'updated_by' => user_id(),
'published_at' => null,
@ -351,6 +352,7 @@ class PodcastController extends BaseController
$this->podcast->is_completed =
$this->request->getPost('complete') === 'yes';
$this->podcast->is_locked = $this->request->getPost('lock') === 'yes';
$this->podcast->is_premium_by_default = $this->request->getPost('premium_by_default') === 'yes';
$this->podcast->updated_by = (int) user_id();
// republish on websub hubs upon edit

View File

@ -288,6 +288,14 @@ class SettingsController extends BaseController
cache()->clean();
}
if ($this->request->getPost('rename_episodes_files') === 'yes') {
$allAudio = (new MediaModel('audio'))->getAllOfType();
foreach ($allAudio as $audio) {
$audio->rename();
}
}
return redirect('settings-general')->with('message', lang('Settings.housekeeping.runSuccess'));
}

View File

@ -14,6 +14,7 @@ return [
->gateway => 'Home',
'podcasts' => 'podcasts',
'episodes' => 'episodes',
'subscriptions' => 'subscriptions',
'contributors' => 'contributors',
'pages' => 'pages',
'settings' => 'settings',

View File

@ -109,6 +109,8 @@ return [
'bonus' => 'Bonus',
'bonus_hint' => 'Extra content for the show (for example, behind the scenes info or interviews with the cast) or cross-promotional content for another show',
],
'premium_title' => 'Premium',
'premium' => 'Episode must only be accessible to premium subscribers',
'parental_advisory' => [
'label' => 'Parental advisory',
'hint' => 'Does the episode contain explicit content?',

View File

@ -107,6 +107,9 @@ return [
'monetization_section_title' => 'Monetization',
'monetization_section_subtitle' =>
'Earn money thanks to your audience.',
'premium' => 'Premium',
'premium_by_default' => 'Episodes must be set as premium by default',
'premium_by_default_hint' => 'Podcast episodes will be marked as premium by default. You can still choose to set some episodes, trailers or bonuses as public.',
'payment_pointer' => 'Payment Pointer for Web Monetization',
'payment_pointer_hint' =>
'This is your where you will receive money thanks to Web Monetization',

View File

@ -25,6 +25,9 @@ return [
'podcast-analytics-players' => 'Players',
'podcast-analytics-listening-time' => 'Listening time',
'podcast-analytics-time-periods' => 'Time periods',
'premium' => 'Premium',
'subscription-list' => 'All subscriptions',
'subscription-add' => 'Add subscription',
'contributors' => 'Contributors',
'contributor-list' => 'All contributors',
'contributor-add' => 'Add contributor',

View File

@ -35,6 +35,8 @@ return [
'reset_counts_helper' => 'This option will recalculate and reset all data counts (number of followers, posts, comments, …).',
'rewrite_media' => 'Rewrite media metadata',
'rewrite_media_helper' => 'This option will delete all superfluous media files and recreate them (images, audio files, transcripts, chapters, …)',
'rename_episodes_files' => 'Rename episode audio files',
'rename_episodes_files_hint' => 'This option will rename all episodes audio files to a random string of characters. Use this if one of your private episodes link was leaked as this will effectively hide it.',
'clear_cache' => 'Clear all cache',
'clear_cache_helper' => 'This option will flush redis cache or writable/cache files.',
'run' => 'Run housekeeping',

View File

@ -10,12 +10,16 @@ declare(strict_types=1);
namespace Modules\Analytics\Controllers;
use App\Entities\Episode;
use App\Models\EpisodeModel;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
use Modules\Analytics\Config\Analytics;
use Modules\PremiumPodcasts\Models\SubscriptionModel;
use Psr\Log\LoggerInterface;
class EpisodeAnalyticsController extends Controller
@ -48,14 +52,14 @@ class EpisodeAnalyticsController extends Controller
$this->config = config('Analytics');
}
public function hit(string $base64EpisodeData, string ...$audioPath): RedirectResponse
public function hit(string $base64EpisodeData, string ...$audioPath): RedirectResponse|ResponseInterface
{
$session = Services::session();
$session->start();
$serviceName = '';
if (isset($_GET['_from'])) {
$serviceName = $_GET['_from'];
if ($this->request->getGet('_from')) {
$serviceName = $this->request->getGet('_from');
} elseif ($session->get('embed_domain') !== null) {
$serviceName = $session->get('embed_domain');
} elseif ($session->get('referer') !== null && $session->get('referer') !== '- Direct -') {
@ -67,6 +71,40 @@ class EpisodeAnalyticsController extends Controller
base64_url_decode($base64EpisodeData),
);
if (! $episodeData) {
throw PageNotFoundException::forPageNotFound();
}
// check if episode is premium?
$episode = (new EpisodeModel())->getEpisodeById($episodeData['episodeId']);
if (! $episode instanceof Episode) {
return $this->response->setStatusCode(404);
}
$subscription = null;
// check if podcast is already unlocked before any token validation
if ($episode->is_premium && ($subscription = service('premium_podcasts')->subscription(
$episode->podcast->handle
)) === null) {
// look for token as GET parameter
if (($token = $this->request->getGet('token')) === null) {
return $this->response->setStatusCode(
401,
'Episode is premium, you must provide a token to unlock it.'
);
}
// check if there's a valid subscription for the provided token
if (($subscription = (new SubscriptionModel())->validateSubscription(
$episode->podcast->handle,
$token
)) === null) {
return $this->response->setStatusCode(401, 'Invalid token!');
}
}
podcast_hit(
$episodeData['podcastId'],
$episodeData['episodeId'],
@ -75,8 +113,9 @@ class EpisodeAnalyticsController extends Controller
$episodeData['duration'],
$episodeData['publicationDate'],
$serviceName,
$subscription !== null ? $subscription->id : null
);
return redirect()->to($this->config->getAudioUrl(['podcasts', ...$audioPath]));
return redirect()->to($this->config->getAudioUrl($episode->audio->file_path));
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace Modules\Analytics\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddAnalyticsPodcastsBySubscription extends Migration
{
public function up(): void
{
$this->forge->addField([
'podcast_id' => [
'type' => 'INT',
'unsigned' => true,
],
'episode_id' => [
'type' => 'INT',
'unsigned' => true,
],
'subscription_id' => [
'type' => 'INT',
'unsigned' => true,
],
'date' => [
'type' => 'DATE',
],
'hits' => [
'type' => 'INT',
'unsigned' => true,
'default' => 1,
],
]);
$this->forge->addPrimaryKey(['podcast_id', 'episode_id', 'subscription_id', 'date']);
// `created_at` and `updated_at` are created with SQL because Model class wont be used for insertion (Procedure will be used instead)
$this->forge->addField('`created_at` timestamp NOT NULL DEFAULT current_timestamp()');
$this->forge->addField(
'`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()',
);
$this->forge->createTable('analytics_podcasts_by_subscription');
}
public function down(): void
{
$this->forge->dropTable('analytics_podcasts_by_subscription');
}
}

View File

@ -38,7 +38,8 @@ class AddAnalyticsPodcastsProcedure extends Migration
IN `p_filesize` INT UNSIGNED,
IN `p_duration` DECIMAL(8,3) UNSIGNED,
IN `p_age` INT UNSIGNED,
IN `p_new_listener` TINYINT(1) UNSIGNED
IN `p_new_listener` TINYINT(1) UNSIGNED,
IN `p_subscription_id` INT UNSIGNED
) MODIFIES SQL DATA
DETERMINISTIC
SQL SECURITY INVOKER
@ -69,6 +70,12 @@ class AddAnalyticsPodcastsProcedure extends Migration
INSERT INTO `{$prefix}analytics_podcasts_by_region`(`podcast_id`, `country_code`, `region_code`, `latitude`, `longitude`, `date`)
VALUES (p_podcast_id, p_country_code, p_region_code, p_latitude, p_longitude, @current_date)
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
IF `p_subscription_id` THEN
INSERT INTO `{$prefix}analytics_podcasts_by_subscription`(`podcast_id`, `episode_id`, `subscription_id`, `date`)
VALUES (p_podcast_id, p_episode_id, p_subscription_id, @current_date)
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
END IF;
END IF;
INSERT INTO `{$prefix}analytics_podcasts_by_player`(`podcast_id`, `service`, `app`, `device`, `os`, `is_bot`, `date`)
VALUES (p_podcast_id, p_service, p_app, p_device, p_os, p_bot, @current_date)

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
/**
* @property int $podcast_id
* @property int $episode_id
* @property int $subscription_id
* @property Time $date
* @property int $hits
* @property Time $created_at
* @property Time $updated_at
*/
class AnalyticsPodcastsBySubscription extends Entity
{
/**
* @var string[]
*/
protected $dates = ['date', 'created_at', 'updated_at'];
/**
* @var array<string, string>
*/
protected $casts = [
'podcast_id' => 'integer',
'episode_id' => 'integer',
'subscription_id' => 'integer',
'hits' => 'integer',
];
}

View File

@ -41,7 +41,9 @@ if (! function_exists('generate_episode_analytics_url')) {
function generate_episode_analytics_url(
int $podcastId,
int $episodeId,
string $audioPath,
string $podcastHandle,
string $episodeSlug,
string $audioExtension,
float $audioDuration,
int $audioFileSize,
int $audioFileHeaderSize,
@ -66,7 +68,7 @@ if (! function_exists('generate_episode_analytics_url')) {
$publicationDate->getTimestamp(),
),
),
$audioPath,
$podcastHandle . '/' . $episodeSlug . '.' . $audioExtension,
);
}
}
@ -263,7 +265,8 @@ if (! function_exists('podcast_hit')) {
int $fileSize,
float $duration,
int $publicationTime,
string $serviceName
string $serviceName,
?int $subscriptionId,
): void {
$session = Services::session();
$session->start();
@ -353,7 +356,7 @@ if (! function_exists('podcast_hit')) {
->save($podcastListenerHashId, $downloadsByUser, $secondsToMidnight);
$db->query(
"CALL {$procedureName}(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);",
"CALL {$procedureName}(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);",
[
$podcastId,
$episodeId,
@ -370,6 +373,7 @@ if (! function_exists('podcast_hit')) {
$duration,
$age,
$newListener,
$subscriptionId,
],
);
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace Modules\Analytics\Models;
use CodeIgniter\Model;
use Modules\Analytics\Entities\AnalyticsPodcastsBySubscription;
class AnalyticsPodcastBySubscriptionModel extends Model
{
/**
* @var string
*/
protected $table = 'analytics_podcasts_by_subscription';
/**
* @var string
*/
protected $returnType = AnalyticsPodcastsBySubscription::class;
/**
* @var bool
*/
protected $useSoftDeletes = false;
/**
* @var bool
*/
protected $useTimestamps = false;
public function getNumberOfDownloadsLast3Months(int $podcastId, int $subscriptionId): int
{
$cacheName = "{$podcastId}_{$subscriptionId}_analytics_podcast_by_subscription";
if (
! ($found = cache($cacheName))
) {
$found = (int) ($this->builder()
->selectSum('hits', 'total_hits')
->where([
'podcast_id' => $podcastId,
'subscription_id' => $subscriptionId,
])
->where('`date` >= UTC_TIMESTAMP() - INTERVAL 3 month', null, false)
->get()
->getResultArray())[0]['total_hits'];
cache()
->save($cacheName, $found, 600);
}
return $found;
}
}

View File

@ -259,6 +259,8 @@ class InstallController extends Controller
->latest();
$migrations->setNamespace('Modules\Auth')
->latest();
$migrations->setNamespace('Modules\PremiumPodcasts')
->latest();
$migrations->setNamespace('Modules\Analytics')
->latest();
}

View File

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Modules\PremiumPodcasts\Config;
$routes = service('routes');
$routes->addPlaceholder('podcastHandle', '[a-zA-Z0-9\_]{1,32}');
// Admin routes for subscriptions
$routes->group(
config('Admin')
->gateway,
[
'namespace' => 'Modules\PremiumPodcasts\Controllers',
],
static function ($routes): void {
$routes->group('podcasts/(:num)/subscriptions', static function ($routes): void {
$routes->get('/', 'SubscriptionController::list/$1', [
'as' => 'subscription-list',
'filter' =>
'permission:podcasts-view,podcast-manage_subscriptions',
]);
$routes->get('add', 'SubscriptionController::add/$1', [
'as' => 'subscription-add',
'filter' => 'permission:podcast-manage_subscriptions',
]);
$routes->post(
'add',
'SubscriptionController::attemptAdd/$1',
[
'filter' =>
'permission:podcast-manage_subscriptions',
],
);
$routes->post('save-link', 'SubscriptionController::attemptLinkSave/$1', [
'as' => 'subscription-link-save',
'filter' => 'permission:podcast-manage_subscriptions',
]);
// Subscription
$routes->group('(:num)', static function ($routes): void {
$routes->get('/', 'SubscriptionController::view/$1/$2', [
'as' => 'subscription-view',
'filter' =>
'permission:podcast-manage_subscriptions',
]);
$routes->get(
'edit',
'SubscriptionController::edit/$1/$2',
[
'as' => 'subscription-edit',
'filter' =>
'permission:podcast-manage_subscriptions',
],
);
$routes->post(
'edit',
'SubscriptionController::attemptEdit/$1/$2',
[
'as' => 'subscription-edit',
'filter' =>
'permission:podcast-manage_subscriptions',
],
);
$routes->get(
'regenerate-token',
'SubscriptionController::regenerateToken/$1/$2',
[
'as' => 'subscription-regenerate-token',
'filter' =>
'permission:podcast-manage_subscriptions',
]
);
$routes->get(
'suspend',
'SubscriptionController::suspend/$1/$2',
[
'as' => 'subscription-suspend',
'filter' =>
'permission:podcast-manage_subscriptions',
],
);
$routes->post(
'suspend',
'SubscriptionController::attemptSuspend/$1/$2',
[
'filter' =>
'permission:podcast-manage_subscriptions',
],
);
$routes->get(
'resume',
'SubscriptionController::resume/$1/$2',
[
'as' => 'subscription-resume',
'filter' =>
'permission:podcast-manage_subscriptions',
],
);
$routes->get(
'remove',
'SubscriptionController::remove/$1/$2',
[
'as' => 'subscription-remove',
'filter' =>
'permission:podcast-manage_subscriptions',
],
);
$routes->post(
'remove',
'SubscriptionController::attemptRemove/$1/$2',
[
'filter' =>
'permission:podcast-manage_subscriptions',
],
);
});
});
}
);
$routes->group(
'@(:podcastHandle)',
[
'namespace' => 'Modules\PremiumPodcasts\Controllers',
],
static function ($routes): void {
$routes->get('unlock', 'LockController/$1', [
'as' => 'premium-podcast-unlock',
]);
$routes->post('unlock', 'LockController::attemptUnlock/$1', [
'as' => 'premium-podcast-unlock',
]);
$routes->get('lock', 'LockController::attemptLock/$1', [
'as' => 'premium-podcast-lock',
]);
}
);

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Modules\PremiumPodcasts\Config;
use Config\Services as BaseService;
use Modules\PremiumPodcasts\Models\SubscriptionModel;
use Modules\PremiumPodcasts\PremiumPodcasts;
class Services extends BaseService
{
public static function premium_podcasts(?SubscriptionModel $subscriptionModel = null, bool $getShared = true)
{
if ($getShared) {
return self::getSharedInstance('premium_podcasts', $subscriptionModel);
}
$premiumPodcasts = new PremiumPodcasts();
$subscriptionModel ??= model(SubscriptionModel::class);
return $premiumPodcasts
->setSubscriptionModel($subscriptionModel);
}
}

View File

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace Modules\PremiumPodcasts\Controllers;
use App\Controllers\BaseController;
use App\Entities\Podcast;
use App\Models\PodcastModel;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;
use Modules\PremiumPodcasts\PremiumPodcasts;
class LockController extends BaseController
{
protected Podcast $podcast;
protected PremiumPodcasts $premiumPodcasts;
public function __construct()
{
$this->premiumPodcasts = service('premium_podcasts');
}
public function _remap(string $method, string ...$params): mixed
{
if ($params === []) {
throw PageNotFoundException::forPageNotFound();
}
if (($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) === null) {
throw PageNotFoundException::forPageNotFound();
}
$this->podcast = $podcast;
return $this->{$method}();
}
public function index(): string
{
$locale = service('request')
->getLocale();
$cacheName =
"page_podcast#{$this->podcast->id}_{$locale}_unlock" .
(can_user_interact() ? '_authenticated' : '');
if (! ($cachedView = cache($cacheName))) {
$data = [
// TODO: metatags for locked premium podcasts
'metatags' => '',
'podcast' => $this->podcast,
];
helper('form');
if (can_user_interact()) {
return view('podcast/unlock', $data);
}
// The page cache is set to a decade so it is deleted manually upon podcast update
return view('podcast/unlock', $data, [
'cache' => DECADE,
'cache_name' => $cacheName,
]);
}
return $cachedView;
}
public function attemptUnlock(): RedirectResponse
{
$rules = [
'token' => 'required',
];
if (! $this->validate($rules)) {
return redirect()->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
$token = (string) $this->request->getPost('token');
// attempt unlocking the podcast with the token
if (! $this->premiumPodcasts->unlock($this->podcast->handle, $token)) {
// bad key or subscription is not active
return redirect()->back()
->withInput()
->with('error', lang('PremiumPodcasts.messages.unlockBadAttempt'));
}
$redirectURL = session('redirect_url') ?? site_url('/');
unset($_SESSION['redirect_url']);
return redirect()->to($redirectURL)
->withCookies()
->with('message', lang('PremiumPodcasts.messages.unlockSuccess'));
}
public function attemptLock(): RedirectResponse
{
$this->premiumPodcasts->lock($this->podcast->handle);
$redirectURL = session('redirect_url') ?? site_url('/');
unset($_SESSION['redirect_url']);
return redirect()->to($redirectURL)
->withCookies()
->with('message', lang('PremiumPodcasts.messages.lockSuccess'));
}
}

View File

@ -0,0 +1,447 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace Modules\PremiumPodcasts\Controllers;
use App\Entities\Podcast;
use App\Models\PodcastModel;
use CodeIgniter\Email\Email;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\I18n\Time;
use Modules\Admin\Controllers\BaseController;
use Modules\PremiumPodcasts\Entities\Subscription;
use Modules\PremiumPodcasts\Models\SubscriptionModel;
class SubscriptionController extends BaseController
{
protected Podcast $podcast;
protected Subscription $subscription;
public function _remap(string $method, string ...$params): mixed
{
if ($params === []) {
throw PageNotFoundException::forPageNotFound();
}
if (($podcast = (new PodcastModel())->getPodcastById((int) $params[0])) === null) {
throw PageNotFoundException::forPageNotFound();
}
$this->podcast = $podcast;
if (count($params) <= 1) {
return $this->{$method}();
}
if (($this->subscription = (new SubscriptionModel())->getSubscriptionById((int) $params[1])) === null) {
throw PageNotFoundException::forPageNotFound();
}
return $this->{$method}();
}
public function list(): string
{
$data = [
'podcast' => $this->podcast,
];
helper('form');
replace_breadcrumb_params([
0 => $this->podcast->title,
]);
return view('subscription/list', $data);
}
public function attemptLinkSave(): RedirectResponse
{
$rules = [
'subscription_link' => 'valid_url_strict|permit_empty',
];
if (! $this->validate($rules)) {
return redirect()->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
if (($subscriptionLink = $this->request->getPost('subscription_link')) === '') {
service('settings')
->forget('Subscription.link', 'podcast:' . $this->podcast->id);
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'message',
lang('Subscription.messages.linkRemoveSuccess')
);
}
service('settings')
->set('Subscription.link', $subscriptionLink, 'podcast:' . $this->podcast->id);
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'message',
lang('Subscription.messages.linkSaveSuccess')
);
}
public function view(): string
{
$data = [
'podcast' => $this->podcast,
'subscription' => $this->subscription,
];
replace_breadcrumb_params([
0 => $this->podcast->title,
1 => '#' . $this->subscription->id,
]);
return view('subscription/view', $data);
}
public function add(): string
{
helper('form');
$data = [
'podcast' => $this->podcast,
];
replace_breadcrumb_params([
0 => $this->podcast->title,
]);
return view('subscription/add', $data);
}
public function attemptAdd(): RedirectResponse
{
helper('text');
$expiresAt = null;
$expirationDate = $this->request->getPost('expiration_date');
if ($expirationDate) {
$expiresAt = Time::createFromFormat(
'Y-m-d H:i',
$expirationDate,
$this->request->getPost('client_timezone'),
)->setTimezone(app_timezone());
}
$newSubscription = new Subscription([
'podcast_id' => $this->podcast->id,
'email' => $this->request->getPost('email'),
'token' => hash('sha256', $rawToken = random_string('alnum', 8)),
'expires_at' => $expiresAt,
'created_by' => user_id(),
'updated_by' => user_id(),
]);
$db = db_connect();
$db->transStart();
$subscriptionModel = new SubscriptionModel();
if (! $subscriptionModel->insert($newSubscription)) {
$db->transRollback();
return redirect()
->back()
->withInput()
->with('errors', $subscriptionModel->errors());
}
/** @var Email $email */
$email = service('email');
if (! $email->setTo($newSubscription->email)
->setSubject(lang('Subscription.emails.welcome_subject', [
'podcastTitle' => $this->podcast->title,
], $this->podcast->language_code))
->setMessage(view('subscription/email/welcome', [
'subscription' => $newSubscription,
'token' => $rawToken,
]))->setMailType('html')
->send()) {
$db->transRollback();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'errors',
[lang('Subscription.messages.addError'), $email->printDebugger([])]
);
}
$db->transComplete();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'message',
lang('Subscription.messages.addSuccess', [
'subscriber' => $newSubscription->email,
])
);
}
public function regenerateToken(): RedirectResponse
{
helper('text');
$this->subscription->token = hash('sha256', $rawToken = random_string('alnum', 8));
$this->subscription->updated_by = user_id();
$db = db_connect();
$db->transStart();
$subscriptionModel = new SubscriptionModel();
if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) {
$db->transRollback();
return redirect()
->back()
->withInput()
->with('errors', $subscriptionModel->errors());
}
/** @var Email $email */
$email = service('email');
if (! $email->setTo($this->subscription->email)
->setSubject(lang('Subscription.emails.reset_subject', [], $this->podcast->language_code))
->setMessage(view('subscription/email/reset', [
'subscription' => $this->subscription,
'token' => $rawToken,
]))->setMailType('html')
->send()) {
$db->transRollback();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'errors',
[lang('Subscription.messages.regenerateTokenError'), $email->printDebugger([])]
);
}
$db->transComplete();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'message',
lang('Subscription.messages.regenerateTokenSuccess', [
'subscriber' => $this->subscription->email,
])
);
}
public function edit(): string
{
helper('form');
$data = [
'podcast' => $this->podcast,
'subscription' => $this->subscription,
];
replace_breadcrumb_params([
0 => $this->podcast->title,
1 => '#' . $this->subscription->id,
]);
return view('subscription/edit', $data);
}
public function attemptEdit(): RedirectResponse
{
$expiresAt = null;
$expirationDate = $this->request->getPost('expiration_date');
if ($expirationDate) {
$expiresAt = Time::createFromFormat(
'Y-m-d H:i',
$expirationDate,
$this->request->getPost('client_timezone'),
)->setTimezone(app_timezone());
}
$this->subscription->expires_at = $expiresAt;
$db = db_connect();
$db->transStart();
$subscriptionModel = new SubscriptionModel();
if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) {
$db->transRollback();
return redirect()
->back()
->withInput()
->with('errors', $subscriptionModel->errors());
}
/** @var Email $email */
$email = service('email');
if (! $email->setTo($this->subscription->email)
->setSubject(lang('Subscription.emails.edited_subject', [], $this->podcast->language_code))
->setMessage(view('subscription/email/edited', [
'subscription' => $this->subscription,
]))->setMailType('html')
->send()) {
$db->transRollback();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'errors',
[lang('Subscription.messages.editError'), $email->printDebugger([])]
);
}
$db->transComplete();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'message',
lang('Subscription.messages.editSuccess', [
'subscriber' => $this->subscription->email,
])
);
}
public function suspend(): string
{
helper('form');
$data = [
'podcast' => $this->podcast,
'subscription' => $this->subscription,
];
replace_breadcrumb_params([
0 => $this->podcast->title,
1 => '#' . $this->subscription->id,
]);
return view('subscription/suspend', $data);
}
public function attemptSuspend(): RedirectResponse
{
$db = db_connect();
$db->transStart();
$this->subscription->suspend($this->request->getPost('reason'));
$subscriptionModel = new SubscriptionModel();
if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) {
return redirect()
->back()
->with('errors', $subscriptionModel->errors());
}
/** @var Email $email */
$email = service('email');
if (! $email->setTo($this->subscription->email)
->setSubject(lang('Subscription.emails.suspended_subject', [], $this->podcast->language_code))
->setMessage(view('subscription/email/suspended', [
'subscription' => $this->subscription,
]))->setMailType('html')
->send()) {
$db->transRollback();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'errors',
[lang('Subscription.messages.suspendError'), $email->printDebugger([])]
);
}
$db->transComplete();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'messages',
lang('Subscription.messages.suspendSuccess', [
'subscriber' => $this->subscription->email,
])
);
}
public function resume(): RedirectResponse
{
$db = db_connect();
$db->transStart();
$this->subscription->resume();
$subscriptionModel = new SubscriptionModel();
if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) {
return redirect()
->back()
->with('errors', $subscriptionModel->errors());
}
/** @var Email $email */
$email = service('email');
if (! $email->setTo($this->subscription->email)
->setSubject(lang('Subscription.emails.resumed_subject', [], $this->podcast->language_code))
->setMessage(view('subscription/email/resumed', [
'subscription' => $this->subscription,
]))->setMailType('html')
->send()) {
$db->transRollback();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'errors',
[lang('Subscription.messages.resumeError'), $email->printDebugger([])]
);
}
$db->transComplete();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'message',
lang('Subscription.messages.resumeSuccess', [
'subscriber' => $this->subscription->email,
])
);
}
public function remove(): string
{
helper('form');
$data = [
'podcast' => $this->podcast,
'subscription' => $this->subscription,
];
replace_breadcrumb_params([
0 => $this->podcast->title,
1 => '#' . $this->subscription->id,
]);
return view('subscription/delete', $data);
}
public function attemptRemove(): RedirectResponse
{
$db = db_connect();
$db->transStart();
(new SubscriptionModel())->delete($this->subscription->id);
/** @var Email $email */
$email = service('email');
if (! $email->setTo($this->subscription->email)
->setSubject(lang('Subscription.emails.removed_subject', [], $this->podcast->language_code))
->setMessage(view('subscription/email/removed', [
'subscription' => $this->subscription,
]))->setMailType('html')
->send()) {
$db->transRollback();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'errors',
[lang('Subscription.messages.removeError'), $email->printDebugger([])]
);
}
$db->transComplete();
return redirect()->route('subscription-list', [$this->podcast->id])->with(
'messages',
lang('Subscription.messages.removeSuccess', [
'subscriber' => $this->subscription->email,
])
);
}
}

View File

@ -0,0 +1,80 @@
<?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/
*/
namespace Modules\PremiumPodcasts\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSubscriptions extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'INT',
'unsigned' => true,
],
'email' => [
'type' => 'VARCHAR',
'constraint' => 255,
],
'token' => [
'type' => 'VARCHAR',
'constraint' => 64,
],
'status' => [
'type' => 'ENUM',
'constraint' => ['active', 'suspended'],
'default' => 'active',
],
'status_message' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
],
'expires_at' => [
'type' => 'DATETIME',
'null' => true,
],
'created_by' => [
'type' => 'INT',
'unsigned' => true,
],
'updated_by' => [
'type' => 'INT',
'unsigned' => true,
],
'created_at' => [
'type' => 'DATETIME',
],
'updated_at' => [
'type' => 'DATETIME',
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['podcast_id', 'email']);
$this->forge->addUniqueKey('token');
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE');
$this->forge->addForeignKey('created_by', 'users', 'id');
$this->forge->addForeignKey('updated_by', 'users', 'id');
$this->forge->createTable('subscriptions');
}
public function down(): void
{
$this->forge->dropTable('subscriptions');
}
}

View File

@ -0,0 +1,123 @@
<?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/
*/
namespace Modules\PremiumPodcasts\Entities;
use App\Entities\Podcast;
use App\Models\PodcastModel;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
use Modules\Analytics\Models\AnalyticsPodcastBySubscriptionModel;
use RuntimeException;
/**
* @property int $id
* @property int $podcast_id
* @property Podcast|null $podcast
* @property string $email
* @property string $token
* @property string $status
* @property string|null $status_message
* @property Time $expires_at
* @property int $downloads_last_3_months
*
* @property int $created_by
* @property int $updated_by
* @property Time $created_at
* @property Time $updated_at
*/
class Subscription extends Entity
{
protected ?Podcast $podcast = null;
/**
* @var string[]
*/
protected $dates = ['expires_at', 'created_at', 'updated_at'];
/**
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'email' => 'string',
'token' => 'string',
'status' => 'string',
'status_message' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
];
public function getStatus(): string
{
return ($this->expires_at !== null && $this->expires_at->isBefore(
Time::now()
)) ? 'expired' : $this->attributes['status'];
}
/**
* Suspend a subscription.
*
* @return $this
*/
public function suspend(string $reason): static
{
$this->attributes['status'] = 'suspended';
$this->attributes['status_message'] = $reason;
return $this;
}
/**
* Resumes a subscription / unSuspend.
*
* @return $this
*/
public function resume(): static
{
$this->attributes['status'] = 'active';
$this->attributes['status_message'] = null;
return $this;
}
/**
* Checks to see if a subscription has been suspended.
*/
public function isSuspended(): bool
{
return isset($this->attributes['status']) && $this->attributes['status'] === 'suspended';
}
/**
* Returns the subscription's podcast
*/
public function getPodcast(): ?Podcast
{
if ($this->podcast_id === null) {
throw new RuntimeException('Subscription must have a podcast_id before getting podcast.');
}
if (! $this->podcast instanceof Podcast) {
$this->podcast = (new PodcastModel())->getPodcastById($this->podcast_id);
}
return $this->podcast;
}
public function getDownloadsLast3Months(): int
{
return (new AnalyticsPodcastBySubscriptionModel())->getNumberOfDownloadsLast3Months(
$this->podcast_id,
$this->id
);
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Modules\PremiumPodcasts\Filters;
use App\Models\EpisodeModel;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Router\Router;
use Config\App;
use Modules\PremiumPodcasts\PremiumPodcasts;
use Myth\Auth\Authentication\AuthenticationBase;
class PodcastUnlockFilter implements FilterInterface
{
/**
* Verifies that a user is logged in, or redirects to login.
*
* @param array|null $params
*
* @return mixed
*/
public function before(RequestInterface $request, $params = null)
{
if (! function_exists('is_unlocked')) {
helper('premium_podcasts');
}
$current = (string) current_url(true)
->setHost('')
->setScheme('')
->stripQuery('token');
$config = config(App::class);
if ($config->forceGlobalSecureRequests) {
// Remove "https:/"
$current = substr($current, 7);
}
/** @var Router $router */
$router = service('router');
$routerParams = $router->params();
if ($routerParams === []) {
return;
}
// no need to go through the unlock form if user is connected
/** @var AuthenticationBase $auth */
$auth = service('authentication');
if ($auth->isLoggedIn()) {
return;
}
// Make sure this isn't already a premium podcast route
if ($current === route_to('premium-podcast-unlock', $routerParams[0])) {
return;
}
// Make sure that public episodes are still accessible
if ($routerParams >= 2 && ($episode = (new EpisodeModel())->getEpisodeBySlug(
$routerParams[0],
$routerParams[1]
)) && ! $episode->is_premium) {
return;
}
// if podcast is locked then send to the unlock form
/** @var PremiumPodcasts $premiumPodcasts */
$premiumPodcasts = service('premium_podcasts');
if (! $premiumPodcasts->check($routerParams[0])) {
session()->set('redirect_url', current_url());
return redirect()->route('premium-podcast-unlock', [$routerParams[0]]);
}
}
/**
* @param array|null $arguments
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use Modules\PremiumPodcasts\Entities\Subscription;
use Modules\PremiumPodcasts\PremiumPodcasts;
if (! function_exists('is_podcast_unlocked')) {
function is_unlocked(string $podcastHandle): bool
{
/** @var PremiumPodcasts $premiumPodcast */
$premiumPodcast = service('premium_podcasts');
return $premiumPodcast->check($podcastHandle);
}
}
if (! function_exists('subscription')) {
/**
* Returns the Subscription instance for the currently active subscription.
*/
function subscription(string $podcastHandle): ?Subscription
{
/** @var PremiumPodcasts $premiumPodcast */
$premiumPodcast = service('premium_podcasts');
$premiumPodcast->check($podcastHandle);
return $premiumPodcast->subscription($podcastHandle);
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'podcast_is_premium' => 'Podcast contains premium episodes',
'episode_is_premium' => 'Episode is premium, only available to premium subscribers',
'unlock_episode' => 'This episode is for premium subscribers only. Click to unlock it!',
'banner_unlock' => 'This podcast contains premium episodes, only available to premium subscribers.',
'banner_lock' => 'Podcast is unlocked, enjoy the premium episodes!',
'subscribe' => 'Subscribe',
'lock' => 'Lock',
'unlock' => 'Unlock',
'unlock_form' => [
'title' => 'Premium content',
'subtitle' => 'This podcast contains locked premium episodes! Do you have the key to unlock them?',
'token' => 'Enter your key',
'token_hint' => 'If you are subscribed to {podcastTitle}, you may copy the key that was sent to you via email and paste it here.',
'submit' => 'Unlock all episodes!',
'call_to_action' => 'Unlock all episodes of {podcastTitle}:',
'subscribe_cta' => 'Subscribe now!',
],
'messages' => [
'unlockSuccess' => 'Podcast was successfully unlocked! Enjoy the premium episodes!',
'unlockBadAttempt' => 'Your key does not seem to be working…',
'lockSuccess' => 'Podcast was successfully locked!',
],
];

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'podcast_subscriptions' => 'Podcast subscriptions',
'add' => 'New subscription',
'view' => 'View subscription',
'edit' => 'Edit subscription',
'regenerate_token' => 'Regenerate token',
'suspend' => 'Suspend subscription',
'resume' => 'Resume subscription',
'delete' => 'Delete subscription',
'status' => [
'active' => 'Active',
'suspended' => 'Suspended',
'expired' => 'Expired',
],
'list' => [
'number' => 'Number',
'email' => 'Email',
'expiration_date' => 'Expiration date',
'unlimited' => 'Unlimited',
'downloads' => 'Downloads',
'status' => 'Status',
],
'form' => [
'email' => 'Email',
'expiration_date' => 'Expiration date',
'expiration_date_hint' => 'The date and time at which the subscription expires. Leave empty for an unlimited subscription.',
'submit_add' => 'Add subscription',
'submit_edit' => 'Edit subscription',
],
'form_link_add' => [
'link' => 'Subscription page link',
'link_hint' => 'This will add a call to action in the website inviting listeners to subscribe to the podcast.',
'submit' => 'Save link',
],
'suspend_form' => [
'disclaimer' => 'Suspending the subscription will restrict the subscriber from having access to the premium content. You will still be able to lift the suspension afterwards.',
'reason' => 'Reason',
'reason_placeholder' => 'Why are you suspending the subscription?',
"submit" => 'Suspend subscription',
],
'delete_form' => [
'disclaimer' => 'Deleting {subscriber}\'s subscription will remove all analytics data associated with it.',
'understand' => 'I understand, remove the subscription permanently',
'submit' => 'Remove subscription',
],
'messages' => [
'addSuccess' => 'New subscription added! A welcome email was sent to {subscriber}.',
'addError' => 'Subscription could not be added.',
'editSuccess' => 'Subscription expiry date was updated! An email was sent to {subscriber}.',
'editError' => 'Subscription could not be edited.',
'regenerateTokenSuccess' => 'Token regenerated! An email was sent to {subscriber} with the new token.',
'regenerateTokenError' => 'Token could not be regenerated.',
'removeSuccess' => 'Subscription was canceled! An email was sent to {subscriber} to tell him.',
'removeError' => 'Subscription could not be canceled.',
'suspendSuccess' => 'Subscription was suspended! An email was sent to {subscriber}.',
'suspendError' => 'Subscription could not be suspended.',
'resumeSuccess' => 'Subscription was resumed! An email was sent to {subscriber}.',
'resumeError' => 'Subscription could not be resumed.',
'linkSaveSuccess' => 'Subscription link was saved successfully! It will appear in the website as a Call To Action!',
'linkRemoveSuccess' => 'Subscription link was removed successfully!',
],
'emails' => [
'greeting' => 'Hey,',
'token' => 'Your token: {0}',
'unique_feed_link' => 'Your unique feed link: {0}',
'how_to_use' => 'How to use?',
'two_ways' => 'You have two ways of unlocking the premium episodes:',
'import_into_app' => 'Copy your unique feed url inside your favourite podcast app (import it as a private feed to prevent exposing your credentials).',
'go_to_website' => 'Go to {podcastWebsite}\'s website and unlock the podcast with your token.',
'welcome_subject' => 'Welcome to {podcastTitle}',
'welcome' => 'You have subscribed to {podcastTitle}, thank you and welcome aboard!',
'welcome_token_title' => 'Here are your credentials to unlock the podcast\'s premium episodes:',
'welcome_expires' => 'Your subscription was set to expire on {0}.',
'welcome_never_expires' => 'Your subscription was set to never expire.',
'reset_subject' => 'Your token was reset!',
'reset_token' => 'Your access to {podcastTitle} has been reset!',
'reset_token_title' => 'New credentials have been generated for you to unlock the podcast\'s premium episodes:',
'edited_subject' => 'Your subscription has been updated!',
'edited_expires' => 'Your subscription for {podcastTitle} was set to expire on {expiresAt}.',
'edited_never_expires' => 'Your subscription for {podcastTitle} was set to never expire!',
'suspended_subject' => 'Your subscription has been suspended!',
'suspended' => 'Your subscription for {podcastTitle} has been suspended! You can no longer access the podcast\'s premium episodes.',
'suspended_reason' => 'That is for the following reason: {0}',
'resumed_subject' => 'Your subscription has been resumed!',
'resumed' => 'Your subscription for {podcastTitle} has been resumed! You may access the podcast\'s premium episodes again.',
'removed_subject' => 'Your subscription has been removed!',
'removed' => 'Your subscription for {podcastTitle} has been removed! You no longer have access to the podcast\'s premium episodes.',
'footer' => '{castopod} hosted on {host}',
],
];

View File

@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/**
* Class SoundbiteModel Model for podcasts_soundbites table in database
*
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace Modules\PremiumPodcasts\Models;
use CodeIgniter\Model;
use Modules\PremiumPodcasts\Entities\Subscription;
class SubscriptionModel extends Model
{
/**
* @var string
*/
protected $table = 'subscriptions';
/**
* @var string
*/
protected $primaryKey = 'id';
/**
* @var string[]
*/
protected $allowedFields = [
'id',
'podcast_id',
'email',
'token',
'status',
'status_message',
'expires_at',
'created_by',
'updated_by',
];
/**
* @noRector
*/
protected $returnType = Subscription::class;
/**
* @var bool
*/
protected $useSoftDeletes = false;
/**
* @var bool
*/
protected $useTimestamps = true;
/**
* @var string[]
*/
protected $afterUpdate = ['clearCache'];
/**
* @var string[]
*/
protected $beforeDelete = ['clearCache'];
public function getSubscriptionById(int $subscriptionId): ?Subscription
{
$cacheName = "subscription#{$subscriptionId}";
if (! ($found = cache($cacheName))) {
$found = $this->find($subscriptionId);
cache()
->save($cacheName, $found, DECADE);
}
return $found;
}
/**
* @return Subscription[]
*/
public function getPodcastSubscriptions(int $podcastId): array
{
$cacheName = "podcast#{$podcastId}_subscriptions";
if (! ($found = cache($cacheName))) {
$found = $this->where('podcast_id', $podcastId)
->findAll();
cache()
->save($cacheName, $found, DECADE);
}
return $found;
}
/**
* @param string $token plain-text token to be encrypted and matched against encrypted tokens in database
*/
public function validateSubscription(int|string $podcastIdOrHandle, string $token): ?Subscription
{
$subscriptionModel = $this;
if (is_int($podcastIdOrHandle)) {
$this->where('id', $podcastIdOrHandle);
} else {
$this->select('subscriptions.*')
->where('handle', $podcastIdOrHandle)
->join('podcasts', 'podcasts.id = subscriptions.podcast_id');
}
return $subscriptionModel
->where([
'token' => hash('sha256', $token),
'status' => 'active',
'expires_at' => null,
])
->orWhere('`expires_at` > UTC_TIMESTAMP()', null, false)
->first();
}
/**
* @param mixed[] $data
*
* @return mixed[]
*/
protected function clearCache(array $data): array
{
$subscription = (new self())->find(is_array($data['id']) ? $data['id'][0] : $data['id']);
cache()
->delete("subscription#{$subscription->id}");
cache()
->delete("podcast#{$subscription->podcast_id}_subscriptions");
return $data;
}
}

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Modules\PremiumPodcasts;
use CodeIgniter\Events\Events;
use Modules\PremiumPodcasts\Entities\Subscription;
use Modules\PremiumPodcasts\Models\SubscriptionModel;
class PremiumPodcasts
{
protected SubscriptionModel $subscriptionModel;
/**
* @var array<string, Subscription|null>
*/
protected $subscriptions = [];
public function setSubscriptionModel(SubscriptionModel $subscriptionModel): self
{
$this->subscriptionModel = $subscriptionModel;
return $this;
}
public function unlock(string $podcastHandle, string $token): bool
{
$subscription = $this->subscriptionModel->validateSubscription($podcastHandle, $token);
if (! $subscription instanceof Subscription) {
$this->subscriptions[$podcastHandle] = null;
return false;
}
$this->subscriptions[$podcastHandle] = $subscription;
$session = session();
$session->set("{$podcastHandle}:subscription", $subscription);
Events::trigger('unlock', $podcastHandle, $subscription);
return true;
}
public function lock(string $podcastHandle): bool
{
if (! $this->isUnlocked($podcastHandle)) {
return true;
}
$this->subscriptions[$podcastHandle] = null;
unset($_SESSION["{$podcastHandle}:subscription"]);
Events::trigger('lock', $podcastHandle);
return true;
}
public function isUnlocked(string $podcastHandle): bool
{
if (array_key_exists(
$podcastHandle,
$this->subscriptions
) && ($this->subscriptions[$podcastHandle] instanceof Subscription)) {
return true;
}
if ($subscription = session()->get("{$podcastHandle}:subscription")) {
$this->subscriptions[$podcastHandle] = $subscription;
return true;
}
return false;
}
public function check(string $podcastHandle): bool
{
// check if locked, no need to go any further
if (! $this->isUnlocked($podcastHandle)) {
return false;
}
// Store the current subscription object
$this->subscriptions[$podcastHandle] = $this->subscriptionModel->getSubscriptionById(
$this->subscriptions[$podcastHandle]->id
);
if (! $this->subscriptions[$podcastHandle] instanceof Subscription) {
return false;
}
// lock podcast if subscription is not active
if ($this->subscriptions[$podcastHandle]->status !== 'active') {
$this->lock($podcastHandle);
return false;
}
// All good!
return true;
}
/**
* Returns the Subscription instance for the current logged in user.
*/
public function subscription(string $podcastHandle): ?Subscription
{
return $this->isUnlocked($podcastHandle) ? $this->subscriptions[$podcastHandle] : null;
}
}

View File

@ -10,6 +10,7 @@ parameters:
- app/Helpers
- modules/Analytics/Helpers
- modules/Fediverse/Helpers
- modules/PremiumPodcasts/Helpers
- vendor/codeigniter4/framework/system/Helpers
- vendor/myth/auth/src/Helpers
excludePaths:

View File

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>403 Forbidden</title>
</head>
<body>
<p>Directory access is forbidden.</p>
</body>
</html>

View File

@ -142,6 +142,9 @@ module.exports = {
},
},
},
zIndex: {
60: 60,
},
},
},
variants: {},

View File

@ -31,8 +31,15 @@
<div class="flex flex-col justify-end w-full -mt-4 sticky-header-inner">
<?= render_breadcrumb('text-xs items-center flex') ?>
<div class="flex justify-between py-1">
<div class="flex flex-wrap items-center overflow-x-hidden">
<Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading>
<div class="flex flex-wrap items-center">
<?php if ((isset($episode) && $episode->is_premium) || (isset($podcast) && $podcast->is_premium)): ?>
<div class="inline-flex items-center">
<IconButton uri="<?= route_to('subscription-list', $podcast->id) ?>" glyph="exchange-dollar" variant="secondary" class="p-0 mr-2 text-4xl border-0"><?= isset($episode) ? lang('PremiumPodcasts.episode_is_premium') : lang('PremiumPodcasts.podcast_is_premium') ?></IconButton>
<Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading>
</div>
<?php else: ?>
<Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading>
<?php endif; ?>
<?= $this->renderSection('headerLeft') ?>
</div>
<div class="flex flex-shrink-0 gap-x-2"><?= $this->renderSection('headerRight') ?></div>

View File

@ -1,4 +1,4 @@
<div data-sidebar-toggler="backdrop" role="button" tabIndex="0" aria-label="Close" class="fixed z-50 hidden w-full h-full bg-gray-800/75 md:hidden"></div>
<div data-sidebar-toggler="backdrop" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>" class="fixed z-50 hidden w-full h-full bg-gray-800/75 md:hidden"></div>
<aside data-sidebar-toggler="sidebar" data-toggle-class="-translate-x-full" data-hide-class="-translate-x-full" class="h-full max-h-[calc(100vh-40px)] sticky z-50 flex flex-col row-start-2 col-start-1 text-white transition duration-200 ease-in-out transform -translate-x-full border-r top-10 border-navigation bg-navigation md:translate-x-0">
<?php if (isset($podcast) && isset($episode)): ?>
<?= $this->include('episode/_sidebar') ?>

View File

@ -1,10 +1,17 @@
<article class="relative flex flex-col flex-1 flex-shrink-0 w-full transition group overflow-hidden bg-elevated border-3 snap-center hover:shadow-lg focus-within:shadow-lg focus-within:ring-accent border-subtle rounded-xl min-w-[12rem] max-w-[17rem]">
<article class="relative flex flex-col flex-1 flex-shrink-0 w-full transition group overflow-hidden bg-elevated border-3 snap-center hover:shadow-lg focus-within:shadow-lg focus-within:ring-accent rounded-xl min-w-[12rem] max-w-[17rem] <?= $episode->is_premium ? 'border-accent-base' : 'border-subtle' ?>">
<a href="<?= route_to('episode-view', $episode->podcast->id, $episode->id) ?>" class="flex flex-col justify-end w-full h-full text-white group">
<div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div>
<div class="w-full h-full overflow-hidden bg-header">
<img src="<?= $episode->cover->medium_url ?>" alt="<?= esc($episode->title) ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform group-focus:scale-105 group-hover:scale-105 aspect-square" loading="lazy" />
</div>
<?= publication_pill($episode->published_at, $episode->publication_status, 'absolute top-0 left-0 ml-2 mt-2 text-sm'); ?>
<?php if ($episode->is_premium): ?>
<div class="absolute top-0 left-0 inline-flex mt-2 gap-x-2">
<Icon glyph="exchange-dollar" class="w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg text-accent-contrast bg-accent-base" />
<?= publication_pill($episode->published_at, $episode->publication_status, 'text-sm'); ?>
</div>
<?php else: ?>
<?= publication_pill($episode->published_at, $episode->publication_status, 'absolute top-0 left-0 ml-2 mt-2 text-sm'); ?>
<?php endif; ?>
<div class="absolute z-20 flex flex-col items-start px-4 py-2">
<?= episode_numbering($episode->number, $episode->season_number, 'text-xs font-semibold !no-underline px-1 bg-black/50 mr-1', true) ?>
<span class="font-semibold leading-tight line-clamp-2"><?= esc($episode->title) ?></span>

View File

@ -21,7 +21,10 @@ $podcastNavigation = [
/>
<span class="flex-1 w-full px-2 text-xs font-semibold truncate" title="<?= esc($podcast->title) ?>"><?= esc($podcast->title) ?></span>
</a>
<div class="flex items-center px-4 py-2 border-y border-navigation">
<div class="relative flex items-center px-4 py-2 border-y border-navigation">
<?php if ($episode->is_premium): ?>
<Icon glyph="exchange-dollar" class="absolute pl-1 text-xl rounded-r-full rounded-tl-lg left-4 top-4 text-accent-contrast bg-accent-base" />
<?php endif; ?>
<img
src="<?= $episode->cover->thumbnail_url ?>"
alt="<?= esc($episode->title) ?>"

View File

@ -107,6 +107,8 @@
isChecked="false" ><?= lang('Episode.form.parental_advisory.explicit') ?></Forms.RadioButton>
</fieldset>
</Forms.Section>
@ -131,6 +133,11 @@
</Forms.Section>
<Forms.Section title="<?= lang('Episode.form.premium_title') ?>">
<Forms.Toggler class="mt-2" name="premium" value="yes" checked="<?= $podcast->is_premium_by_default ? 'true' : 'false' ?>">
<?= lang('Episode.form.premium') ?></Forms.Toggler>
</Forms.Section>
<Forms.Section
title="<?= lang('Episode.form.location_section_title') ?>"
subtitle="<?= lang('Episode.form.location_section_subtitle') ?>"

View File

@ -113,7 +113,6 @@
</Forms.Section>
<Forms.Section
title="<?= lang('Episode.form.show_notes_section_title') ?>"
subtitle="<?= lang('Episode.form.show_notes_section_subtitle') ?>">
@ -136,6 +135,11 @@
</Forms.Section>
<Forms.Section title="<?= lang('Episode.form.premium_title') ?>" >
<Forms.Toggler class="mt-2" name="premium" value="yes" checked="<?= $episode->is_premium ? 'true' : 'false' ?>">
<?= lang('Episode.form.premium') ?></Forms.Toggler>
</Forms.Section>
<Forms.Section
title="<?= lang('Episode.form.location_section_title') ?>"
subtitle="<?= lang('Episode.form.location_section_subtitle') ?>"

View File

@ -42,6 +42,11 @@ data_table(
[
'header' => lang('Episode.list.episode'),
'cell' => function ($episode, $podcast) {
$premiumBadge = '';
if ($episode->is_premium) {
$premiumBadge = '<Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" />';
}
return '<div class="flex">' .
'<div class="relative flex-shrink-0 mr-2">' .
'<time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/50" datetime="PT' . round($episode->audio->duration, 3) . 'S">' .
@ -49,6 +54,7 @@ data_table(
(int) $episode->audio->duration,
) .
'</time>' .
$premiumBadge .
'<img src="' . $episode->cover->thumbnail_url . '" alt="' . esc($episode->title) . '" class="object-cover w-20 rounded-lg shadow-inner aspect-square" loading="lazy" />' .
'</div>' .
'<a class="overflow-x-hidden text-sm hover:underline" href="' . route_to(

View File

@ -1,4 +1,4 @@
<article class="relative h-full overflow-hidden transition shadow bg-elevated border-3 border-subtle group rounded-xl hover:shadow-xl focus-within:shadow-xl focus-within:ring-accent">
<article class="relative h-full overflow-hidden transition shadow bg-elevated border-3 group rounded-xl hover:shadow-xl focus-within:shadow-xl focus-within:ring-accent <?= $podcast->is_premium ? 'border-accent-base' : 'border-subtle' ?>">
<a href="<?= route_to('podcast-view', $podcast->id) ?>" class="flex flex-col justify-end w-full h-full text-white group">
<div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div>
<div class="<?= 'w-full h-full overflow-hidden bg-header' . ($podcast->publication_status !== 'published' ? ' grayscale group-hover:grayscale-[60%]' : '') ?>">
@ -6,13 +6,27 @@
alt="<?= esc($podcast->title) ?>"
src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform aspect-square group-focus:scale-105 group-hover:scale-105" loading="lazy" />
</div>
<?php if ($podcast->publication_status !== 'published'): ?>
<span class="absolute top-0 left-0 flex items-center px-1 mt-2 ml-2 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50">
<?= lang('Podcast.draft') ?>
<?php if ($podcast->publication_status === 'scheduled'): ?>
<Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" />
<?php if ($podcast->is_premium): ?>
<div class="absolute top-0 left-0 inline-flex mt-2 gap-x-2">
<Icon glyph="exchange-dollar" class="w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg text-accent-contrast bg-accent-base" />
<?php if ($podcast->publication_status !== 'published'): ?>
<span class="flex items-center px-1 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50">
<?= lang('Podcast.draft') ?>
<?php if ($podcast->publication_status === 'scheduled'): ?>
<Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" />
<?php endif ?>
</span>
<?php endif ?>
</span>
</div>
<?php else: ?>
<?php if ($podcast->publication_status !== 'published'): ?>
<span class="absolute top-0 left-0 flex items-center px-1 mt-2 ml-2 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50">
<?= lang('Podcast.draft') ?>
<?php if ($podcast->publication_status === 'scheduled'): ?>
<Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" />
<?php endif ?>
</span>
<?php endif ?>
<?php endif ?>
<div class="absolute z-20 w-full px-4 pb-4 transition duration-75 ease-out translate-y-6 group-focus:translate-y-0 group-hover:translate-y-0">
<h2 class="font-bold leading-none truncate font-display"><?= esc($podcast->title) ?></h2>

View File

@ -9,6 +9,10 @@ $podcastNavigation = [
'icon' => 'play-circle',
'items' => ['episode-list', 'episode-create'],
],
'premium' => [
'icon' => 'exchange-dollar',
'items' => ['subscription-list', 'subscription-add'],
],
'analytics' => [
'icon' => 'line-chart',
'items' => [
@ -41,7 +45,10 @@ $counts = [
?>
<div class="flex items-center px-4 py-2 border-b border-navigation">
<div class="relative flex items-center px-4 py-2 border-b border-navigation">
<?php if ($podcast->is_premium): ?>
<Icon glyph="exchange-dollar" class="absolute pl-1 text-xl rounded-r-full rounded-tl-lg left-4 top-4 text-accent-contrast bg-accent-base" />
<?php endif; ?>
<img
src="<?= $podcast->cover->thumbnail_url ?>"
alt="<?= esc($podcast->title) ?>"

View File

@ -148,6 +148,11 @@
</Forms.Section>
<Forms.Section title="<?= lang('Podcast.form.premium') ?>">
<Forms.Toggler class="mt-2" name="premium_by_default" value="yes" checked="false" hint="<?= lang('Podcast.form.premium_by_default_hint') ?>">
<?= lang('Podcast.form.premium_by_default') ?></Forms.Toggler>
</Forms.Section>
<Forms.Section
title="<?= lang('Podcast.form.location_section_title') ?>"
subtitle="<?= lang('Podcast.form.location_section_subtitle') ?>" >

View File

@ -169,6 +169,11 @@
</Forms.Section>
<Forms.Section title="<?= lang('Podcast.form.premium') ?>">
<Forms.Toggler class="mt-2" name="premium_by_default" value="yes" checked="<?= $podcast->is_premium_by_default ? 'true' : 'false' ?>" hint="<?= lang('Podcast.form.premium_by_default_hint') ?>">
<?= lang('Podcast.form.premium_by_default') ?></Forms.Toggler>
</Forms.Section>
<Forms.Section
title="<?= lang('Podcast.form.location_section_title') ?>"
subtitle="<?= lang('Podcast.form.location_section_subtitle') ?>" >

View File

@ -77,9 +77,10 @@
title="<?= lang('Settings.housekeeping.title') ?>"
subtitle="<?= lang('Settings.housekeeping.subtitle') ?>" >
<Forms.Toggler name="reset_counts" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.reset_counts_helper') ?>"><?= lang('Settings.housekeeping.reset_counts') ?></Forms.Toggler>
<Forms.Toggler name="rewrite_media" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.rewrite_media_helper') ?>"><?= lang('Settings.housekeeping.rewrite_media') ?></Forms.Toggler>
<Forms.Toggler name="clear_cache" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.clear_cache_helper') ?>"><?= lang('Settings.housekeeping.clear_cache') ?></Forms.Toggler>
<Forms.Toggler name="reset_counts" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.reset_counts_helper') ?>"><?= lang('Settings.housekeeping.reset_counts') ?></Forms.Toggler>
<Forms.Toggler name="rewrite_media" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.rewrite_media_helper') ?>"><?= lang('Settings.housekeeping.rewrite_media') ?></Forms.Toggler>
<Forms.Toggler name="rename_episodes_files" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.rename_episodes_files_hint') ?>"><?= lang('Settings.housekeeping.rename_episodes_files') ?></Forms.Toggler>
<Forms.Toggler name="clear_cache" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.clear_cache_helper') ?>"><?= lang('Settings.housekeeping.clear_cache') ?></Forms.Toggler>
<Button variant="primary" type="submit" iconLeft="home-gear"><?= lang('Settings.housekeeping.run') ?></Button>

View File

@ -0,0 +1,35 @@
<?= $this->extend('../cp_admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Subscription.add', [esc($podcast->title)]) ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Subscription.add', [esc($podcast->title)]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<form method="POST" action="<?= route_to('subscription-add', $podcast->id) ?>" class="flex flex-col max-w-sm gap-y-4">
<?= csrf_field() ?>
<input type="hidden" name="client_timezone" value="UTC" />
<Forms.Field
name="email"
type="email"
label="<?= lang('Subscription.form.email') ?>"
required="true" />
<Forms.Field
as="DatetimePicker"
name="expiration_date"
label="<?= lang('Subscription.form.expiration_date') ?>"
hint="<?= lang('Subscription.form.expiration_date_hint') ?>"
/>
<Button type="submit" class="self-end" variant="primary"><?= lang('Subscription.form.submit_add') ?></Button>
</form>
<?= $this->endSection() ?>

View File

@ -0,0 +1,29 @@
<?= $this->extend('_layout') ?>
<?= $this->section('title') ?>
<?= lang('Subscription.delete') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Subscription.delete') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<form action="<?= route_to('subscription-delete', $podcast->id, $subscription->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto">
<?= csrf_field() ?>
<Alert variant="danger" glyph="alert" class="font-semibold"><?= lang('Subscription.delete_form.disclaimer', [
'subscriber' => $subscription->email,
]) ?></Alert>
<Forms.Checkbox class="mt-2" name="understand" required="true" isChecked="false"><?= lang('Subscription.delete_form.understand') ?></Forms.Checkbox>
<div class="flex items-center self-end mt-4 gap-x-2">
<Button uri="<?= route_to('subscription-list', $podcast->id) ?>"><?= lang('Common.cancel') ?></Button>
<Button type="submit" variant="danger"><?= lang('Subscription.delete_form.submit') ?></Button>
</div>
</form>
<?= $this->endSection() ?>

View File

@ -0,0 +1,39 @@
<?= $this->extend('../cp_admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Subscription.edit', [esc($podcast->title)]) ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Subscription.edit', [esc($podcast->title)]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<form method="POST" action="<?= route_to('subscription-edit', $podcast->id, $subscription->id) ?>" class="flex flex-col max-w-sm gap-y-4">
<?= csrf_field() ?>
<input type="hidden" name="client_timezone" value="UTC" />
<div class="px-4 py-5 bg-base sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium leading-5 text-skin-muted">
<?= lang('Subscription.list.email') ?>
</dt>
<dd class="mt-1 text-sm leading-5 sm:mt-0 sm:col-span-2">
<?= esc($subscription->email) ?>
</dd>
</div>
<Forms.Field
as="DatetimePicker"
name="expiration_date"
label="<?= lang('Subscription.form.expiration_date') ?>"
hint="<?= lang('Subscription.form.expiration_date_hint') ?>"
value="<?= $subscription->expires_at ?>"
/>
<Button type="submit" class="self-end" variant="primary"><?= lang('Subscription.form.submit_edit') ?></Button>
</form>
<?= $this->endSection() ?>

View File

@ -0,0 +1,4 @@
<ul>
<li><?= lang('Subscription.emails.token', ['<strong>' . $token . '</strong>'], $subscription->podcast->language_code, false) ?> </li>
<li><?= lang('Subscription.emails.unique_feed_link', ['<a href="' . $subscription->podcast->feedUrl . '?token=' . $token . '">' . $subscription->podcast->feedUrl . '?token=' . $token . '</a>'], $subscription->podcast->language_code, false) ?> </li>
</ul>

View File

@ -0,0 +1,6 @@
<br /><br />---<br />
<small><?= lang('Subscription.emails.footer', [
'castopod' => '<a href="https://castopod.org/">Castopod</a>',
'host' => '<a href="' . base_url('', 'https') . '">' . current_domain() . '</a>',
], $subscription->podcast->language_code, false) ?></small>

View File

@ -0,0 +1,8 @@
<h3><?= lang('Subscription.emails.how_to_use', [], $subscription->podcast->language_code) ?></h3>
<p><?= lang('Subscription.emails.two_ways', [], $subscription->podcast->language_code) ?></p>
<ol>
<li><?= lang('Subscription.emails.import_into_app', [], $subscription->podcast->language_code) ?></li>
<li><?= lang('Subscription.emails.go_to_website', [
'podcastWebsite' => '<a href="' . url_to('podcast-episodes', esc($subscription->podcast->handle)) . '">' . $subscription->podcast->title . '</a>',
], $subscription->podcast->language_code, false) ?></li>
</ol>

View File

@ -0,0 +1,18 @@
<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
<?php if ($subscription->expires_at): ?>
<?php
$formatter = new IntlDateFormatter($subscription->podcast->language_code, IntlDateFormatter::LONG, IntlDateFormatter::LONG);
$translatedDate = $subscription->expires_at->toLocalizedString($formatter->getPattern());
?>
<?= lang('Subscription.emails.edited_expires', [
'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
'expiresAt' => '<strong>' . $translatedDate . '</strong>',
], $subscription->podcast->language_code, false) ?>
<?php else: ?>
<?= lang('Subscription.emails.edited_never_expires', [
'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
], $subscription->podcast->language_code, false) ?>
<?php endif; ?>
<?= $this->include('subscription/email/_footer') ?>

View File

@ -0,0 +1,7 @@
<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
<?= lang('Subscription.emails.removed', [
'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
], $subscription->podcast->language_code, false) ?>
<?= $this->include('subscription/email/_footer') ?>

View File

@ -0,0 +1,13 @@
<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
<?= lang('Subscription.emails.reset_token', [
'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
], $subscription->podcast->language_code, false) ?><br/><br/>
<?= lang('Subscription.emails.reset_token_title', [], $subscription->podcast->language_code) ?>
<?= $this->include('subscription/email/_credentials_list') ?>
<?= $this->include('subscription/email/_how_to_use') ?>
<?= $this->include('subscription/email/_footer') ?>

View File

@ -0,0 +1,7 @@
<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
<?= lang('Subscription.emails.resumed', [
'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
], $subscription->podcast->language_code, false) ?>
<?= $this->include('subscription/email/_footer') ?>

View File

@ -0,0 +1,11 @@
<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
<?= lang('Subscription.emails.suspended', [
'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
], $subscription->podcast->language_code, false) ?><br/><br/>
<?php if ($subscription->status_message): ?>
<?= lang('Subscription.emails.suspended_reason', ['<br /><br /><code>' . nl2br($subscription->status_message) . '</code>'], $subscription->podcast->language_code, false) ?>
<?php endif; ?>
<?= $this->include('subscription/email/_footer') ?>

View File

@ -0,0 +1,23 @@
<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
<?= lang('Subscription.emails.welcome', [
'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
], $subscription->podcast->language_code, false) ?><br/><br/>
<?= lang('Subscription.emails.welcome_token_title', [], $subscription->podcast->language_code) ?>
<?= $this->include('subscription/email/_credentials_list') ?>
<?php if ($subscription->expires_at): ?>
<?php
$formatter = new IntlDateFormatter($subscription->podcast->language_code, IntlDateFormatter::LONG, IntlDateFormatter::LONG);
$translatedDate = $subscription->expires_at->toLocalizedString($formatter->getPattern());
?>
<?= lang('Subscription.emails.welcome_expires', ['<strong>' . $translatedDate . '</strong>'], $subscription->podcast->language_code, false) ?>
<?php else: ?>
<?= lang('Subscription.emails.welcome_never_expires', [], $subscription->podcast->language_code) ?>
<?php endif; ?>
<?= $this->include('subscription/email/_how_to_use') ?>
<?= $this->include('subscription/email/_footer') ?>

View File

@ -0,0 +1,130 @@
<?= $this->extend('../cp_admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Subscription.podcast_subscriptions') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Subscription.podcast_subscriptions') ?>
<?= $this->endSection() ?>
<?= $this->section('headerRight') ?>
<Button uri="<?= route_to('subscription-add', $podcast->id) ?>" variant="primary" iconLeft="add"><?= lang('Subscription.add') ?></Button>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<form method="POST" action="<?= route_to('subscription-link-save', $podcast->id) ?>" class="flex flex-col items-start max-w-sm gap-y-1">
<?= csrf_field() ?>
<Forms.Field
class="w-full"
type="url"
name="subscription_link"
label="<?= lang('Subscription.form_link_add.link') ?>"
hint="<?= lang('Subscription.form_link_add.link_hint') ?>"
placeholder="https://…"
value="<?= service('settings')
->get('Subscription.link', 'podcast:' . $podcast->id) ?>" />
<Button variant="primary" type="submit"><?= lang('Subscription.form_link_add.submit') ?></Button>
</form>
<hr class="my-6 border-subtle">
<?= data_table(
[
[
'header' => lang('Subscription.list.number'),
'cell' => function ($subscription) {
return '#' . $subscription->id;
},
],
[
'header' => lang('Subscription.list.email'),
'cell' => function ($subscription) {
return esc($subscription->email);
},
],
[
'header' => lang('Subscription.list.expiration_date'),
'cell' => function ($subscription) {
return $subscription->expires_at ? local_date($subscription->expires_at) : lang('Subscription.list.unlimited');
},
],
[
'header' => lang('Subscription.list.downloads'),
'cell' => function ($subscription) {
return $subscription->downloads_last_3_months;
},
],
[
'header' => lang('Subscription.list.status'),
'cell' => function ($subscription) {
$statusMapping = [
'active' => 'success',
'suspended' => 'warning',
'expired' => 'default',
];
return '<Pill variant="' . $statusMapping[$subscription->status] . '" class="lowercase">' . lang('Subscription.status.' . $subscription->status) . '</Pill>';
},
],
[
'header' => lang('Common.actions'),
'cell' => function ($subscription, $podcast) {
$items = [
[
'type' => 'link',
'title' => lang('Subscription.view'),
'uri' => route_to('subscription-view', $podcast->id, $subscription->id),
],
[
'type' => 'link',
'title' => lang('Subscription.edit'),
'uri' => route_to('subscription-edit', $podcast->id, $subscription->id),
],
[
'type' => 'link',
'title' => lang('Subscription.regenerate_token'),
'uri' => route_to('subscription-regenerate-token', $podcast->id, $subscription->id),
],
[
'type' => 'separator',
],
[
'type' => 'link',
'title' => lang('Subscription.delete'),
'uri' => route_to('subscription-remove', $podcast->id, $subscription->id),
'class' => 'font-semibold text-red-600',
],
];
if ($subscription->status === 'suspended') {
$suspendAction = [[
'type' => 'link',
'title' => lang('Subscription.resume'),
'uri' => route_to('subscription-resume', $podcast->id, $subscription->id),
]];
} else {
$suspendAction = [[
'type' => 'link',
'title' => lang('Subscription.suspend'),
'uri' => route_to('subscription-suspend', $podcast->id, $subscription->id),
]];
}
array_splice($items, 3, 0, $suspendAction);
return '<button id="more-dropdown-' . $subscription->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $subscription->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
icon('more') .
'</button>' .
'<DropdownMenu id="more-dropdown-' . $subscription->id . '-menu" labelledby="more-dropdown-' . $subscription->id . '" offsetY="-24" items="' . esc(json_encode($items)) . '" />';
},
],
],
$podcast->subscriptions,
'',
$podcast,
) ?>
<?= $this->endSection() ?>

View File

@ -0,0 +1,36 @@
<?= $this->extend('_layout') ?>
<?= $this->section('title') ?>
<?= lang('Subscription.suspend') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Subscription.suspend') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<form action="<?= route_to('subscription-suspend', $podcast->id, $subscription->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto">
<?= csrf_field() ?>
<Alert variant="warning" glyph="alert" class="font-semibold"><?= lang('Subscription.suspend_form.disclaimer', [
'email' => $subscription->email,
]) ?></Alert>
<Forms.Field
as="Textarea"
name="reason"
label="<?= lang('Subscription.suspend_form.reason') ?>"
placeholder="<?= lang('Subscription.suspend_form.reason_placeholder') ?>"
rows="4"
class="mt-4"
/>
<div class="flex items-center self-end mt-4 gap-x-2">
<Button uri="<?= route_to('subscription-list', $podcast->id) ?>"><?= lang('Common.cancel') ?></Button>
<Button type="submit" variant="warning" iconLeft="pause"><?= lang('Subscription.suspend_form.submit') ?></Button>
</div>
</form>
<?= $this->endSection() ?>

View File

@ -0,0 +1,19 @@
<?= $this->extend('../cp_admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Subscription.view', [
esc($subscription->id),
]) ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Subscription.view', [
esc($subscription->id),
]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= $subscription->email ?>
<?= $this->endSection() ?>

View File

@ -83,6 +83,9 @@
<div class="z-10 flex flex-col items-start gap-y-2 gap-x-4 sm:flex-row">
<div class="relative flex-shrink-0">
<?= explicit_badge($episode->parental_advisory === 'explicit', 'rounded absolute left-0 bottom-0 ml-2 mb-2 bg-black/75 text-accent-contrast') ?>
<?php if ($episode->is_premium): ?>
<Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" />
<?php endif; ?>
<img src="<?= $episode->cover->medium_url ?>" alt="<?= esc($episode->title) ?>" class="flex-shrink-0 rounded-md shadow-xl h-36 aspect-square" loading="lazy" />
</div>
<div class="flex flex-col items-start w-full min-w-0 text-white">
@ -139,11 +142,12 @@
<?php endif; ?>
</div>
<?= $this->include('episode/_partials/navigation') ?>
<?= $this->include('podcast/_partials/premium_banner') ?>
<div class="relative grid items-start flex-1 col-start-2 grid-cols-podcastMain gap-x-6">
<main class="w-full col-start-1 row-start-1 py-6 col-span-full md:col-span-1">
<?= $this->renderSection('content') ?>
</main>
<div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="Close"></div>
<div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>"></div>
<?= $this->include('podcast/_partials/sidebar') ?>
</div>
<?= view('_persons_modal', [

View File

@ -1,27 +1,36 @@
<article class="flex w-full p-4 shadow bg-elevated rounded-conditional-2xl gap-x-2">
<div class="relative">
<article class="flex w-full p-3 shadow border-3 bg-elevated rounded-conditional-2xl gap-x-2 <?= $episode->is_premium ? 'border-accent-base' : 'border-transparent' ?>">
<div class="relative flex-shrink-0 w-20">
<time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/75" datetime="PT<?= round($episode->audio->duration, 3) ?>S">
<?= format_duration((int) $episode->audio->duration) ?>
</time>
<?php if ($episode->is_premium): ?>
<Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" />
<?php endif; ?>
<img src="<?= $episode->cover
->thumbnail_url ?>" alt="<?= esc($episode->title) ?>" class="object-cover w-20 rounded-lg shadow-inner aspect-square" loading="lazy" />
->thumbnail_url ?>" alt="<?= esc($episode->title) ?>" class="object-cover w-full rounded-lg shadow-inner aspect-square" loading="lazy" />
</div>
<div class="flex items-center flex-1 gap-x-4">
<div class="flex flex-col flex-1">
<div class="inline-flex items-center">
<div class="flex flex-wrap items-center">
<?= episode_numbering($episode->number, $episode->season_number, 'text-xs font-semibold border-subtle text-skin-muted px-1 border mr-2 !no-underline', true) ?>
<?= relative_time($episode->published_at, 'text-xs whitespace-nowrap text-skin-muted') ?>
</div>
<h2 class="flex-1 mt-1 font-semibold leading-tight line-clamp-2"><a class="hover:underline" href="<?= $episode->link ?>"><?= esc($episode->title) ?></a></h2>
</div>
<play-episode-button
id="<?= $episode->id ?>"
imageSrc="<?= $episode->cover->thumbnail_url ?>"
title="<?= esc($episode->title) ?>"
podcast="<?= esc($episode->podcast->title) ?>"
src="<?= $episode->audio_web_url ?>"
mediaType="<?= $episode->audio->file_mimetype ?>"
playLabel="<?= lang('Common.play_episode_button.play') ?>"
playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
<?php if ($episode->is_premium && ! subscription($podcast->handle)): ?>
<a href="<?= route_to('episode', $episode->podcast->handle, $episode->slug) ?>" class="p-3 rounded-full bg-brand bg-accent-base text-accent-contrast hover:bg-accent-hover focus:ring-accent" title="<?= lang('PremiumPodcasts.unlock_episode') ?>" data-tooltip="bottom">
<Icon glyph="lock" class="text-xl" />
</a>
<?php else: ?>
<play-episode-button
id="<?= $episode->id ?>"
imageSrc="<?= $episode->cover->thumbnail_url ?>"
title="<?= esc($episode->title) ?>"
podcast="<?= esc($episode->podcast->title) ?>"
src="<?= $episode->audio_web_url ?>"
mediaType="<?= $episode->audio->file_mimetype ?>"
playLabel="<?= lang('Common.play_episode_button.play') ?>"
playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
<?php endif; ?>
</div>
</article>

View File

@ -3,9 +3,12 @@
<time class="absolute px-1 text-sm font-semibold text-white rounded bg-black/75 bottom-2 right-2" datetime="PT<?= round($episode->audio->duration, 3) ?>S">
<?= format_duration((int) $episode->audio->duration) ?>
</time>
<?php if ($episode->is_premium): ?>
<Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" />
<?php endif; ?>
<img
src="<?= $episode->cover->thumbnail_url ?>"
alt="<?= esc($episode->title) ?>" class="w-24 h-24 aspect-square" loading="lazy" />
src="<?= $episode->cover->thumbnail_url ?>"
alt="<?= esc($episode->title) ?>" class="w-24 h-24 aspect-square" loading="lazy" />
</div>
<div class="flex flex-col flex-1 px-4 py-2">
<div class="inline-flex">
@ -14,14 +17,20 @@
</div>
<a href="<?= $episode->link ?>" class="flex items-baseline font-semibold line-clamp-2" title="<?= esc($episode->title) ?>"><?= esc($episode->title) ?></a>
</div>
<play-episode-button
class="mr-4"
id="<?= $index . '_' . $episode->id ?>"
imageSrc="<?= $episode->cover->thumbnail_url ?>"
title="<?= esc($episode->title) ?>"
podcast="<?= esc($episode->podcast->title) ?>"
src="<?= $episode->audio_web_url ?>"
mediaType="<?= $episode->audio->file_mimetype ?>"
playLabel="<?= lang('Common.play_episode_button.play') ?>"
playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
<?php if ($episode->is_premium && ! subscription($episode->podcast->handle)): ?>
<a href="<?= route_to('episode', $episode->podcast->handle, $episode->slug) ?>" class="p-3 mr-4 rounded-full bg-brand bg-accent-base text-accent-contrast hover:bg-accent-hover focus:ring-accent" title="<?= lang('PremiumPodcasts.unlock_episode') ?>" data-tooltip="bottom">
<Icon glyph="lock" class="text-xl" />
</a>
<?php else: ?>
<play-episode-button
class="mr-4"
id="<?= $index . '_' . $episode->id ?>"
imageSrc="<?= $episode->cover->thumbnail_url ?>"
title="<?= esc($episode->title) ?>"
podcast="<?= esc($episode->podcast->title) ?>"
src="<?= $episode->audio_web_url ?>"
mediaType="<?= $episode->audio->file_mimetype ?>"
playLabel="<?= lang('Common.play_episode_button.play') ?>"
playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
<?php endif; ?>
</div>

View File

@ -76,11 +76,18 @@
<div class="grid gap-4 mt-4 grid-cols-cards">
<?php if ($podcasts): ?>
<?php foreach ($podcasts as $podcast): ?>
<a href="<?= $podcast->link ?>" class="relative w-full h-full overflow-hidden transition shadow focus:ring-accent rounded-xl border-subtle hover:shadow-xl focus:shadow-xl group border-3">
<a href="<?= $podcast->link ?>" class="relative w-full h-full overflow-hidden transition shadow focus:ring-accent rounded-xl hover:shadow-xl focus:shadow-xl group border-3 <?= $podcast->is_premium ? 'border-accent-base' : 'border-subtle' ?>">
<article class="text-white">
<div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div>
<div class="w-full h-full overflow-hidden bg-header">
<?= explicit_badge($podcast->parental_advisory === 'explicit', 'absolute top-0 left-0 z-10 rounded bg-black/75 ml-2 mt-2') ?>
<?php if ($podcast->is_premium): ?>
<div class="absolute top-0 left-0 z-10 inline-flex items-center mt-2 gap-x-2">
<Icon glyph="exchange-dollar" class="w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg text-accent-contrast bg-accent-base" />
<?= explicit_badge($podcast->parental_advisory === 'explicit', 'rounded bg-black/75') ?>
</div>
<?php else: ?>
<?= explicit_badge($podcast->parental_advisory === 'explicit', 'absolute top-0 left-0 z-10 rounded bg-black/75 ml-2 mt-2') ?>
<?php endif; ?>
<img alt="<?= esc($podcast->title) ?>" src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform bg-header aspect-square group-focus:scale-105 group-hover:scale-105" loading="lazy" />
</div>
<div class="absolute bottom-0 left-0 z-20 w-full px-4 pb-2">

View File

@ -43,7 +43,7 @@
</div>
<?php endif; ?>
<header class="relative z-50 flex flex-col-reverse justify-between w-full col-start-2 bg-top bg-no-repeat bg-cover sm:flex-row sm:items-end bg-header aspect-[3/1]" style="background-image: url('<?= $podcast->banner->medium_url ?>');">
<header class="min-h-[200px] relative z-50 flex flex-col-reverse justify-between w-full gap-x-2 col-start-2 bg-top bg-no-repeat bg-cover sm:flex-row sm:items-end bg-header aspect-[3/1]" style="background-image: url('<?= $podcast->banner->medium_url ?>');">
<div class="absolute bottom-0 left-0 w-full h-full backdrop-gradient mix-blend-multiply"></div>
<div class="z-10 flex items-center pl-4 -mb-6 md:pl-8 md:-mb-8 gap-x-4">
<img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= esc($podcast->title) ?>" class="h-24 rounded-full sm:h-28 md:h-36 ring-3 ring-background-elevated aspect-square" loading="lazy" />
@ -77,6 +77,7 @@
</div>
</header>
<?= $this->include('podcast/_partials/navigation') ?>
<?= $this->include('podcast/_partials/premium_banner') ?>
<div class="relative grid items-start flex-1 col-start-2 grid-cols-podcastMain gap-x-6">
<main class="w-full max-w-xl col-start-1 row-start-1 py-6 mx-auto col-span-full md:col-span-1">
<?= $this->renderSection('content') ?>

View File

@ -0,0 +1,46 @@
<?php declare(strict_types=1);
if ($podcast->is_premium): ?>
<?php
$isUnlocked = service('premium_podcasts')
->isUnlocked($podcast->handle);
$shownIcon = $isUnlocked ? 'lock-unlock' : 'lock';
$hiddenIcon = $isUnlocked ? 'lock' : 'lock-unlock';
?>
<div class="flex flex-col items-center justify-between col-start-2 px-2 py-1 mt-2 sm:px-1 md:mt-4 rounded-conditional-full gap-y-2 sm:flex-row bg-accent-base gap-x-2 text-accent-contrast">
<p class="inline-flex items-center text-sm md:pl-4 gap-x-2"><?= $isUnlocked ? lang('PremiumPodcasts.banner_lock') : lang('PremiumPodcasts.banner_unlock') ?></p>
<?php if ($subscriptionLink = service('settings')->get('Subscription.link', 'podcast:' . $podcast->id)): ?>
<div class="flex items-center self-end gap-x-2">
<Button
variant="primary"
class="group"
size="small"
uri="<?= $isUnlocked ? route_to('premium-podcast-lock', $podcast->handle) : route_to('premium-podcast-unlock', $podcast->handle) ?>"
>
<Icon glyph="<?= $shownIcon ?>" class="text-sm group-focus:hidden group-hover:hidden" />
<Icon glyph="<?= $hiddenIcon ?>" class="hidden text-sm group-focus:block group-hover:block" />
<?= $isUnlocked ? lang('PremiumPodcasts.lock') : lang('PremiumPodcasts.unlock') ?>
</Button>
<Button
iconLeft="external-link"
target="_blank"
rel="noopener noreferrer"
variant="secondary"
size="small"
class="tracking-wider uppercase"
uri="<?= $subscriptionLink ?>"><?= lang('PremiumPodcasts.subscribe') ?></Button>
</div>
<?php else: ?>
<Button
variant="primary"
class="self-end group"
size="small"
uri="<?= $isUnlocked ? route_to('premium-podcast-lock', $podcast->handle) : route_to('premium-podcast-unlock', $podcast->handle) ?>"
>
<Icon glyph="<?= $shownIcon ?>" class="text-sm group-focus:hidden group-hover:hidden" />
<Icon glyph="<?= $hiddenIcon ?>" class="hidden text-sm group-focus:block group-hover:block" />
<?= $isUnlocked ? lang('PremiumPodcasts.lock') : lang('PremiumPodcasts.unlock') ?>
</Button>
<?php endif; ?>
</div>
<?php endif; ?>

View File

@ -1,4 +1,4 @@
<div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="Close"></div>
<div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>"></div>
<aside id="podcast-sidebar" data-sidebar-toggler="sidebar" data-toggle-class="hidden" data-hide-class="hidden" class="z-20 hidden h-full col-span-1 col-start-2 row-start-1 p-4 py-6 shadow-2xl md:shadow-none md:block bg-base">
<div class="sticky z-10 bg-base top-12">
<a href="<?= route_to('podcast_feed', esc($podcast->handle)) ?>" class="inline-flex items-center mb-6 text-sm font-semibold focus:ring-accent text-skin-muted hover:text-skin-base group" target="_blank" rel="noopener noreferrer">

View File

@ -6,12 +6,12 @@
<form action="<?= route_to('post-attempt-create', esc(interact_as_actor()->username)) ?>" method="POST" class="flex p-4 shadow bg-elevated gap-x-2 rounded-conditional-2xl">
<?= csrf_field() ?>
<?= view('_message_block') ?>
<img src="<?= interact_as_actor()
->avatar_image_url ?>" alt="<?= esc(interact_as_actor()
->display_name) ?>" class="w-10 h-10 rounded-full aspect-square" loading="lazy" />
<div class="flex flex-col flex-1 min-w-0 gap-y-2">
<?= view('_message_block') ?>
<Forms.Textarea
name="message"
required="true"

Some files were not shown because too many files have changed in this diff Show More