diff --git a/Dockerfile b/Dockerfile index 723d1bc2ece1a2310cda5bebaa06d4e14b142281..0b3f2e6c78c75a7c8b201bdc515c9e7163df8b2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,8 @@ RUN apt-get update \ # gd for image processing && docker-php-ext-configure gd --with-webp --with-jpeg --with-freetype \ && docker-php-ext-install gd \ + && docker-php-ext-install exif \ + && docker-php-ext-enable exif \ # redis extension for cache && pecl install -o -f redis \ && rm -rf /tmp/pear \ diff --git a/app/Controllers/MapController.php b/app/Controllers/MapController.php index c4a6cd4bde29f23f39d11e9d332a8a058c395c31..785a1352aeb4aafe53ce2094481e73997376e7c6 100644 --- a/app/Controllers/MapController.php +++ b/app/Controllers/MapController.php @@ -46,7 +46,7 @@ class MapController extends BaseController 'location_url' => $episode->location->url, 'episode_link' => $episode->link, 'podcast_link' => $episode->podcast->link, - 'cover_path' => $episode->cover->thumbnail_url, + 'cover_url' => $episode->cover->thumbnail_url, 'podcast_title' => $episode->podcast->title, 'episode_title' => $episode->title, ]; diff --git a/app/Database/Migrations/2020-05-29-120000_add_media.php b/app/Database/Migrations/2020-05-29-120000_add_media.php new file mode 100644 index 0000000000000000000000000000000000000000..711ba069f855c7bc66ecf5fc5c3871396fc213d7 --- /dev/null +++ b/app/Database/Migrations/2020-05-29-120000_add_media.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class AddMedia extends Migration +{ + public function up(): void + { + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'auto_increment' => true, + ], + 'file_path' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'file_size' => [ + 'type' => 'INT', + 'unsigned' => true, + 'comment' => 'File size in bytes', + ], + 'file_content_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + ], + 'file_metadata' => [ + 'type' => 'JSON', + 'nullable' => true, + ], + 'type' => [ + 'type' => 'ENUM', + 'constraint' => ['image', 'audio', 'video', 'transcript', 'chapters', 'document'], + ], + 'description' => [ + 'type' => 'TEXT', + ], + 'language_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 2, + ], + 'uploaded_by' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'updated_by' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'uploaded_at' => [ + 'type' => 'DATETIME', + ], + 'updated_at' => [ + 'type' => 'DATETIME', + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addForeignKey('uploaded_by', 'users', 'id'); + $this->forge->addForeignKey('updated_by', 'users', 'id'); + $this->forge->createTable('media'); + } + + public function down(): void + { + $this->forge->dropTable('media'); + } +} diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php index 0663392a1b0f6dcfcb61923eea3ab69864723e03..ff2f913c470720ca8b526edef09e677a6e19edc4 100644 --- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php +++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php @@ -46,25 +46,13 @@ class AddPodcasts extends Migration 'description_html' => [ 'type' => 'TEXT', ], - 'cover_path' => [ - 'type' => 'VARCHAR', - 'constraint' => 255, - ], - // constraint is 13 because the longest safe mimetype for images is image/svg+xml, - // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types - 'cover_mimetype' => [ - 'type' => 'VARCHAR', - 'constraint' => 13, - ], - 'banner_path' => [ - 'type' => 'VARCHAR', - 'constraint' => 255, - 'null' => true, - 'default' => null, + 'cover_id' => [ + 'type' => 'INT', + 'unsigned' => true, ], - 'banner_mimetype' => [ - 'type' => 'VARCHAR', - 'constraint' => 13, + 'banner_id' => [ + 'type' => 'INT', + 'unsigned' => true, 'null' => true, 'default' => null, ], @@ -209,6 +197,8 @@ class AddPodcasts extends Migration $this->forge->addUniqueKey('guid'); $this->forge->addUniqueKey('actor_id'); $this->forge->addForeignKey('actor_id', config('Fediverse')->tablesPrefix . 'actors', 'id', '', 'CASCADE'); + $this->forge->addForeignKey('cover_id', 'media', 'id'); + $this->forge->addForeignKey('banner_id', 'media', 'id'); $this->forge->addForeignKey('category_id', 'categories', 'id'); $this->forge->addForeignKey('language_code', 'languages', 'code'); $this->forge->addForeignKey('created_by', 'users', 'id'); diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php index aea2a023c375ef0bd4b503a8f383b7cad938f8c3..04656978eacce8a57e1bbbf4cbf456b0bb5d7405 100644 --- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php +++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php @@ -40,29 +40,9 @@ class AddEpisodes extends Migration 'type' => 'VARCHAR', 'constraint' => 128, ], - 'audio_file_path' => [ - 'type' => 'VARCHAR', - 'constraint' => 255, - ], - 'audio_file_duration' => [ - // exact value for duration with max 99999,999 ~ 27.7 hours - 'type' => 'DECIMAL(8,3)', - 'unsigned' => true, - 'comment' => 'Playtime in seconds', - ], - 'audio_file_mimetype' => [ - 'type' => 'VARCHAR', - 'constraint' => 255, - ], - 'audio_file_size' => [ - 'type' => 'INT', - 'unsigned' => true, - 'comment' => 'File size in bytes', - ], - 'audio_file_header_size' => [ + 'audio_id' => [ 'type' => 'INT', 'unsigned' => true, - 'comment' => 'Header size in bytes', ], 'description_markdown' => [ 'type' => 'TEXT', @@ -70,34 +50,27 @@ class AddEpisodes extends Migration 'description_html' => [ 'type' => 'TEXT', ], - 'cover_path' => [ - 'type' => 'VARCHAR', - 'constraint' => 255, - 'null' => true, - ], - // constraint is 13 because the longest safe mimetype for images is image/svg+xml, - // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types - 'cover_mimetype' => [ - 'type' => 'VARCHAR', - 'constraint' => 13, + 'cover_id' => [ + 'type' => 'INT', + 'unsigned' => true, 'null' => true, ], - 'transcript_file_path' => [ - 'type' => 'VARCHAR', - 'constraint' => 255, + 'transcript_id' => [ + 'type' => 'INT', + 'unsigned' => true, 'null' => true, ], - 'transcript_file_remote_url' => [ + 'transcript_remote_url' => [ 'type' => 'VARCHAR', 'constraint' => 512, 'null' => true, ], - 'chapters_file_path' => [ - 'type' => 'VARCHAR', - 'constraint' => 255, + 'chapters_id' => [ + 'type' => 'INT', + 'unsigned' => true, 'null' => true, ], - 'chapters_file_remote_url' => [ + 'chapters_remote_url' => [ 'type' => 'VARCHAR', 'constraint' => 512, 'null' => true, @@ -183,6 +156,10 @@ class AddEpisodes extends Migration $this->forge->addPrimaryKey('id'); $this->forge->addUniqueKey(['podcast_id', 'slug']); $this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE'); + $this->forge->addForeignKey('audio_id', 'media', 'id'); + $this->forge->addForeignKey('cover_id', 'media', 'id'); + $this->forge->addForeignKey('transcript_id', 'media', 'id'); + $this->forge->addForeignKey('chapters_id', 'media', 'id'); $this->forge->addForeignKey('created_by', 'users', 'id'); $this->forge->addForeignKey('updated_by', 'users', 'id'); $this->forge->createTable('episodes'); diff --git a/app/Database/Migrations/2020-12-25-120000_add_persons.php b/app/Database/Migrations/2020-12-25-120000_add_persons.php index 58e26df3078b42ba57a05522de0b22fdb0219ee6..66b53ba1ad088a375d2de4eee336c959267d86e3 100644 --- a/app/Database/Migrations/2020-12-25-120000_add_persons.php +++ b/app/Database/Migrations/2020-12-25-120000_add_persons.php @@ -42,16 +42,9 @@ class AddPersons extends Migration 'The url to a relevant resource of information about the person, such as a homepage or third-party profile platform.', 'null' => true, ], - 'avatar_path' => [ - 'type' => 'VARCHAR', - 'constraint' => 255, - 'null' => true, - ], - // constraint is 13 because the longest safe mimetype for images is image/svg+xml, - // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types - 'avatar_mimetype' => [ - 'type' => 'VARCHAR', - 'constraint' => 13, + 'avatar_id' => [ + 'type' => 'INT', + 'unsigned' => true, 'null' => true, ], 'created_by' => [ @@ -71,6 +64,7 @@ class AddPersons extends Migration ]); $this->forge->addKey('id', true); + $this->forge->addForeignKey('avatar_id', 'media', 'id'); $this->forge->addForeignKey('created_by', 'users', 'id'); $this->forge->addForeignKey('updated_by', 'users', 'id'); $this->forge->createTable('persons'); diff --git a/app/Database/Migrations/2020-06-05-180000_add_soundbites.php b/app/Database/Migrations/2021-12-09-130000_add_clips.php similarity index 71% rename from app/Database/Migrations/2020-06-05-180000_add_soundbites.php rename to app/Database/Migrations/2021-12-09-130000_add_clips.php index 90573d51947e3070f9a537d018c9bc94719c7367..068c66b39dd9be1ccbebe7db4ef404bc7da5ee51 100644 --- a/app/Database/Migrations/2020-06-05-180000_add_soundbites.php +++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php @@ -3,9 +3,7 @@ declare(strict_types=1); /** - * Class AddSoundbites Creates soundbites table in database - * - * @copyright 2020 Podlibre + * @copyright 2021 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ */ @@ -14,7 +12,7 @@ namespace App\Database\Migrations; use CodeIgniter\Database\Migration; -class AddSoundbites extends Migration +class AddClips extends Migration { public function up(): void { @@ -37,7 +35,7 @@ class AddSoundbites extends Migration 'unsigned' => true, ], 'duration' => [ - // soundbite duration cannot be higher than 9999,999 seconds ~ 2.77 hours + // clip duration cannot be higher than 9999,999 seconds ~ 2.77 hours 'type' => 'DECIMAL(7,3)', 'unsigned' => true, ], @@ -46,6 +44,21 @@ class AddSoundbites extends Migration 'constraint' => 128, 'null' => true, ], + 'type' => [ + 'type' => 'ENUM', + 'constraint' => ['audio', 'video'], + ], + 'media_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['queued', 'pending', 'generating', 'passed', 'failed'], + ], + 'logs' => [ + 'type' => 'TEXT', + ], 'created_by' => [ 'type' => 'INT', 'unsigned' => true, @@ -65,17 +78,19 @@ class AddSoundbites extends Migration 'null' => true, ], ]); + $this->forge->addKey('id', true); - $this->forge->addUniqueKey(['episode_id', 'start_time', 'duration']); + $this->forge->addUniqueKey(['episode_id', 'start_time', 'duration', 'type']); $this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE'); $this->forge->addForeignKey('episode_id', 'episodes', 'id', '', 'CASCADE'); + $this->forge->addForeignKey('media_id', 'media', 'id', '', 'CASCADE'); $this->forge->addForeignKey('created_by', 'users', 'id'); $this->forge->addForeignKey('updated_by', 'users', 'id'); - $this->forge->createTable('soundbites'); + $this->forge->createTable('clips'); } public function down(): void { - $this->forge->dropTable('soundbites'); + $this->forge->dropTable('clips'); } } diff --git a/app/Entities/Audio.php b/app/Entities/Audio.php new file mode 100644 index 0000000000000000000000000000000000000000..4a342d41db30a5bb9eeacc3483b6ff442ecd0b62 --- /dev/null +++ b/app/Entities/Audio.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use CodeIgniter\Files\File; +use JamesHeinrich\GetID3\GetID3; + +/** + * @property float $duration + * @property int $header_size + */ +class Audio extends Media +{ + protected string $type = 'audio'; + + /** + * @param array<string, mixed>|null $data + */ + public function __construct(array $data = null) + { + parent::__construct($data); + + if ($this->file_metadata) { + $this->duration = (float) $this->file_metadata['playtime_seconds']; + $this->header_size = (int) $this->file_metadata['avdataoffset']; + } + } + + public function setFile(File $file): self + { + parent::setFile($file); + + $getID3 = new GetID3(); + $audioMetadata = $getID3->analyze((string) $file); + + $this->attributes['file_content_type'] = $audioMetadata['mimetype']; + $this->attributes['file_size'] = $audioMetadata['filesize']; + $this->attributes['description'] = $audioMetadata['comments']['comment']; + $this->attributes['file_metadata'] = $audioMetadata; + + return $this; + } +} diff --git a/app/Entities/Soundbite.php b/app/Entities/Clip.php similarity index 84% rename from app/Entities/Soundbite.php rename to app/Entities/Clip.php index f6e85cfd6035440759fb7643cb7a30ef73d50901..550cf4039147df6ae21d3d33c225582af56d6876 100644 --- a/app/Entities/Soundbite.php +++ b/app/Entities/Clip.php @@ -22,7 +22,7 @@ use CodeIgniter\Entity\Entity; * @property int $created_by * @property int $updated_by */ -class Soundbite extends Entity +class Clip extends Entity { /** * @var array<string, string> @@ -33,7 +33,11 @@ class Soundbite extends Entity 'episode_id' => 'integer', 'start_time' => 'double', 'duration' => 'double', + 'type' => 'string', 'label' => '?string', + 'media_id' => 'integer', + 'status' => 'string', + 'logs' => 'string', 'created_by' => 'integer', 'updated_by' => 'integer', ]; diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 711e2bc733b69ad423e76c9e543c67527dc305e4..b5ca18463e8972f73cfe051d4549b5257fc27758 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -11,14 +11,14 @@ declare(strict_types=1); namespace App\Entities; use App\Libraries\SimpleRSSElement; +use App\Models\ClipsModel; use App\Models\EpisodeCommentModel; +use App\Models\MediaModel; use App\Models\PersonModel; use App\Models\PodcastModel; use App\Models\PostModel; -use App\Models\SoundbiteModel; use CodeIgniter\Entity\Entity; use CodeIgniter\Files\File; -use CodeIgniter\HTTP\Files\UploadedFile; use CodeIgniter\I18n\Time; use League\CommonMark\CommonMarkConverter; use RuntimeException; @@ -31,30 +31,22 @@ use RuntimeException; * @property string $guid * @property string $slug * @property string $title - * @property File $audio_file - * @property string $audio_file_url + * @property int $audio_id + * @property Audio $audio * @property string $audio_file_analytics_url * @property string $audio_file_web_url * @property string $audio_file_opengraph_url - * @property string $audio_file_path - * @property double $audio_file_duration - * @property string $audio_file_mimetype - * @property int $audio_file_size - * @property int $audio_file_header_size * @property string|null $description Holds text only description, striped of any markdown or html special characters * @property string $description_markdown * @property string $description_html + * @property int $cover_id * @property Image $cover - * @property string|null $cover_path - * @property string|null $cover_mimetype - * @property File|null $transcript_file - * @property string|null $transcript_file_url - * @property string|null $transcript_file_path - * @property string|null $transcript_file_remote_url - * @property File|null $chapters_file - * @property string|null $chapters_file_url - * @property string|null $chapters_file_path - * @property string|null $chapters_file_remote_url + * @property int|null $transcript_id + * @property Media|null $transcript + * @property string|null $transcript_remote_url + * @property int|null $chapters_id + * @property Media|null $chapters + * @property string|null $chapters_remote_url * @property string|null $parental_advisory * @property int $number * @property int $season_number @@ -86,15 +78,15 @@ class Episode extends Entity protected string $link; - protected File $audio_file; + protected Audio $audio; - protected string $audio_file_url; + protected string $audio_url; - protected string $audio_file_analytics_url; + protected string $audio_analytics_url; - protected string $audio_file_web_url; + protected string $audio_web_url; - protected string $audio_file_opengraph_url; + protected string $audio_opengraph_url; protected string $embed_url; @@ -102,9 +94,9 @@ class Episode extends Entity protected ?string $description = null; - protected File $transcript_file; + protected ?Media $transcript; - protected File $chapters_file; + protected ?Media $chapters; /** * @var Person[]|null @@ -112,9 +104,9 @@ class Episode extends Entity protected ?array $persons = null; /** - * @var Soundbite[]|null + * @var Clip[]|null */ - protected ?array $soundbites = null; + protected ?array $clips = null; /** * @var Post[]|null @@ -146,19 +138,14 @@ class Episode extends Entity 'guid' => 'string', 'slug' => 'string', 'title' => 'string', - 'audio_file_path' => 'string', - 'audio_file_duration' => 'double', - 'audio_file_mimetype' => 'string', - 'audio_file_size' => 'integer', - 'audio_file_header_size' => 'integer', + 'audio_id' => 'integer', 'description_markdown' => 'string', 'description_html' => 'string', - 'cover_path' => '?string', - 'cover_mimetype' => '?string', - 'transcript_file_path' => '?string', - 'transcript_file_remote_url' => '?string', - 'chapters_file_path' => '?string', - 'chapters_file_remote_url' => '?string', + 'cover_id' => '?integer', + 'transcript_id' => '?integer', + 'transcript_remote_url' => '?string', + 'chapters_id' => '?integer', + 'chapters_remote_url' => '?string', 'parental_advisory' => '?string', 'number' => '?integer', 'season_number' => '?integer', @@ -199,108 +186,45 @@ class Episode extends Entity public function getCover(): Image { - if ($coverPath = $this->attributes['cover_path']) { - return new Image(null, $coverPath, $this->attributes['cover_mimetype'], config( - 'Images' - )->podcastCoverSizes); + if (! $this->cover instanceof Image) { + $this->cover = (new MediaModel('image'))->getMediaById($this->cover_id); } - return $this->getPodcast() - ->cover; + return $this->cover; } - /** - * Saves an audio file - */ - public function setAudioFile(UploadedFile | File $audioFile): static - { - helper(['media', 'id3']); - - $audioMetadata = get_file_tags($audioFile); - - $this->attributes['audio_file_path'] = save_media( - $audioFile, - 'podcasts/' . $this->getPodcast()->handle, - $this->attributes['slug'], - ); - $this->attributes['audio_file_duration'] = - $audioMetadata['playtime_seconds']; - $this->attributes['audio_file_mimetype'] = $audioMetadata['mime_type']; - $this->attributes['audio_file_size'] = $audioMetadata['filesize']; - $this->attributes['audio_file_header_size'] = - $audioMetadata['avdataoffset']; - - return $this; - } - - /** - * Saves an episode transcript file - */ - public function setTranscriptFile(UploadedFile | File $transcriptFile): static + public function getAudio(): Audio { - helper('media'); - - $this->attributes['transcript_file_path'] = save_media( - $transcriptFile, - 'podcasts/' . $this->getPodcast() - ->handle, - $this->attributes['slug'] . '-transcript', - ); - - return $this; - } - - /** - * Saves an episode chapters file - */ - public function setChaptersFile(UploadedFile | File $chaptersFile): static - { - helper('media'); - - $this->attributes['chapters_file_path'] = save_media( - $chaptersFile, - 'podcasts/' . $this->getPodcast() - ->handle, - $this->attributes['slug'] . '-chapters', - ); - - return $this; - } - - public function getAudioFile(): File - { - helper('media'); + if (! $this->audio) { + $this->audio = (new MediaModel('audio'))->getMediaById($this->audio_id); + } - return new File(media_path($this->audio_file_path)); + return $this->audio; } - public function getTranscriptFile(): ?File + public function getTranscript(): ?Media { - if ($this->attributes['transcript_file_path']) { - helper('media'); - - return new File(media_path($this->attributes['transcript_file_path'])); + if ($this->transcript_id !== null && $this->transcript === null) { + $this->transcript = (new MediaModel('document'))->getMediaById($this->transcript_id); } - return null; + return $this->transcript; } - public function getChaptersFile(): ?File + public function getChaptersFile(): ?Media { - if ($this->attributes['chapters_file_path']) { - helper('media'); - - return new File(media_path($this->attributes['chapters_file_path'])); + if ($this->chapters_id !== null && $this->chapters === null) { + $this->chapters = (new MediaModel('document'))->getMediaById($this->chapters_id); } - return null; + return $this->chapters; } public function getAudioFileUrl(): string { helper('media'); - return media_base_url($this->audio_file_path); + return media_base_url($this->audio->file_path); } public function getAudioFileAnalyticsUrl(): string @@ -308,15 +232,15 @@ class Episode extends Entity helper('analytics'); // remove 'podcasts/' from audio file path - $strippedAudioFilePath = substr($this->audio_file_path, 9); + $strippedAudioFilePath = substr($this->audio->file_path, 9); return generate_episode_analytics_url( $this->podcast_id, $this->id, $strippedAudioFilePath, - $this->audio_file_duration, - $this->audio_file_size, - $this->audio_file_header_size, + $this->audio->duration, + $this->audio->file_size, + $this->audio->header_size, $this->published_at, ); } @@ -332,28 +256,26 @@ class Episode extends Entity } /** - * Gets transcript url from transcript file uri if it exists or returns the transcript_file_remote_url which can be - * null. + * Gets transcript url from transcript file uri if it exists or returns the transcript_remote_url which can be null. */ - public function getTranscriptFileUrl(): ?string + public function getTranscriptUrl(): ?string { - if ($this->attributes['transcript_file_path']) { - return media_base_url($this->attributes['transcript_file_path']); + if ($this->transcript !== null) { + return $this->transcript->url; } - return $this->attributes['transcript_file_remote_url']; + return $this->transcript_remote_url; } /** - * Gets chapters file url from chapters file uri if it exists or returns the chapters_file_remote_url which can be - * null. + * Gets chapters file url from chapters file uri if it exists or returns the chapters_remote_url which can be null. */ public function getChaptersFileUrl(): ?string { - if ($this->chapters_file_path) { - return media_base_url($this->chapters_file_path); + if ($this->chapters) { + return $this->chapters->url; } - return $this->chapters_file_remote_url; + return $this->chapters_remote_url; } /** @@ -375,21 +297,21 @@ class Episode extends Entity } /** - * Returns the episode’s soundbites + * Returns the episode’s clips * - * @return Soundbite[] + * @return Clip[] */ - public function getSoundbites(): array + public function getClips(): array { if ($this->id === null) { - throw new RuntimeException('Episode must be created before getting soundbites.'); + throw new RuntimeException('Episode must be created before getting clips.'); } - if ($this->soundbites === null) { - $this->soundbites = (new SoundbiteModel())->getEpisodeSoundbites($this->getPodcast() ->id, $this->id); + if ($this->clips === null) { + $this->clips = (new ClipsModel())->getEpisodeClips($this->getPodcast() ->id, $this->id); } - return $this->soundbites; + return $this->clips; } /** diff --git a/app/Entities/Image.php b/app/Entities/Image.php index be46133fb67fa1954b8b67b5971743a4dfbdee39..758e2fa4c55b37ef7ed354fbe140fad3ebd7a839 100644 --- a/app/Entities/Image.php +++ b/app/Entities/Image.php @@ -10,176 +10,68 @@ declare(strict_types=1); namespace App\Entities; -use CodeIgniter\Entity\Entity; use CodeIgniter\Files\File; -use Config\Images; -use RuntimeException; -/** - * @property File|null $file - * @property string $dirname - * @property string $filename - * @property string $extension - * @property string $mimetype - * @property string $path - * @property string $url - */ -class Image extends Entity +class Image extends Media { - protected Images $config; - - protected File $file; - - protected string $dirname; - - protected string $filename; - - protected string $extension; - - protected string $mimetype; + protected string $type = 'image'; /** - * @var array<string, array<string, int|string>> + * @param array<string, mixed>|null $data */ - protected array $sizes = []; - - /** - * @param array<string, array<string, int|string>> $sizes - * @param File $file - */ - public function __construct(?File $file, string $path = '', string $mimetype = '', array $sizes = []) + public function __construct(array $data = null) { - if ($file === null && $path === '') { - throw new RuntimeException('File or path must be set to create an Image.'); - } - - $dirname = ''; - $filename = ''; - $extension = ''; + parent::__construct($data); - if ($file !== null) { - $dirname = $file->getPath(); - $filename = $file->getBasename(); - $extension = $file->getExtension(); - $mimetype = $file->getMimeType(); + if ($this->file_path && $this->file_metadata) { + $this->sizes = $this->file_metadata['sizes']; + $this->initSizeProperties(); } - - if ($path !== '') { - [ - 'filename' => $filename, - 'dirname' => $dirname, - 'extension' => $extension, - ] = pathinfo($path); - } - - if ($file === null) { - helper('media'); - $file = new File(media_path($path)); - } - - $this->file = $file; - $this->dirname = $dirname; - $this->filename = $filename; - $this->extension = $extension; - $this->mimetype = $mimetype; - $this->sizes = $sizes; } - public function __get($property) + public function initSizeProperties(): bool { - // Convert to CamelCase for the method - $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $property))); - - // if a get* method exists for this property, - // call that method to get this value. - // @phpstan-ignore-next-line - if (method_exists($this, $method)) { - return $this->{$method}(); - } - - $fileSuffix = ''; - if ($lastUnderscorePosition = strrpos($property, '_')) { - $fileSuffix = '_' . substr($property, 0, $lastUnderscorePosition); - } - - $path = ''; - if ($this->dirname !== '.') { - $path .= $this->dirname . '/'; - } - $path .= $this->filename . $fileSuffix; + helper('media'); - $extension = '.' . $this->extension; + $extension = $this->file_extension; $mimetype = $this->mimetype; - if ($fileSuffix !== '') { - $sizeName = substr($fileSuffix, 1); - if (array_key_exists('extension', $this->sizes[$sizeName])) { - $extension = '.' . $this->sizes[$sizeName]['extension']; + foreach ($this->sizes as $name => $size) { + if (array_key_exists('extension', $size)) { + $extension = $size['extension']; } - if (array_key_exists('mimetype', $this->sizes[$sizeName])) { - $mimetype = $this->sizes[$sizeName]['mimetype']; + if (array_key_exists('mimetype', $size)) { + $mimetype = $size['mimetype']; } + $this->{$name . '_path'} = $this->file_directory . '/' . $this->file_name . '_' . $name . '.' . $extension; + $this->{$name . '_url'} = media_base_url($this->{$name . '_path'}); + $this->{$name . '_mimetype'} = $mimetype; } - $path .= $extension; - if (str_ends_with($property, 'mimetype')) { - return $mimetype; - } - - if (str_ends_with($property, 'url')) { - helper('media'); - - return media_base_url($path); - } - - if (str_ends_with($property, 'path')) { - return $path; - } - } - - public function getMimetype(): string - { - return $this->mimetype; - } - - public function getFile(): File - { - return $this->file; + return true; } - /** - * @param array<string, array<string, int|string>> $sizes - */ - public function saveImage(array $sizes, string $dirname, string $filename): void + public function setFile(File $file): self { - helper('media'); + parent::setFile($file); - $this->dirname = $dirname; - $this->filename = $filename; - $this->sizes = $sizes; + $metadata = exif_read_data(media_path($this->file_path), null, true); - save_media($this->file, $this->dirname, $this->filename); + if ($metadata) { + $metadata['sizes'] = $this->sizes; + $this->attributes['file_size'] = $metadata['FILE']['FileSize']; + $this->attributes['file_metadata'] = json_encode($metadata); + } + // save derived sizes $imageService = service('image'); - - foreach ($sizes as $name => $size) { + foreach ($this->sizes as $name => $size) { $pathProperty = $name . '_path'; $imageService - ->withFile(media_path($this->path)) + ->withFile(media_path($this->file_path)) ->resize($size['width'], $size['height']); $imageService->save(media_path($this->{$pathProperty})); } - } - - /** - * @param array<string, int[]> $sizes - */ - public function delete(array $sizes): void - { - helper('media'); - foreach (array_keys($sizes) as $name) { - $pathProperty = $name . '_path'; - unlink(media_path($this->{$pathProperty})); - } + return $this; } } diff --git a/app/Entities/ImageOLD.php b/app/Entities/ImageOLD.php new file mode 100644 index 0000000000000000000000000000000000000000..d46b29e141ff8a08ff565b34be02df11b12eb326 --- /dev/null +++ b/app/Entities/ImageOLD.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use CodeIgniter\Files\File; +use Config\Images; + +class Image extends Media +{ + /** + * @var array<string, array<string, int|string>> + */ + public array $sizes = []; + + protected Images $config; + + protected string $type = 'image'; + + public function __get($property) + { + if (str_ends_with($property, '_url') || str_ends_with($property, '_path') || str_ends_with( + $property, + '_mimetype' + )) { + $this->initSizeProperties(); + } + + parent::__get($property); + } + + public function setFileMetadata(string $metadata): self + { + $this->attributes['file_metadata'] = $metadata; + + $metadataArray = json_decode($metadata, true); + if (! array_key_exists('sizes', $metadataArray)) { + return $this; + } + + $this->sizes = $metadataArray['sizes']; + + return $this; + } + + public function initSizeProperties(): bool + { + if ($this->file_path === '') { + return false; + } + + if ($this->sizes === []) { + $this->sizes = $this->file_metadata['sizes']; + } + + helper('media'); + + $extension = $this->file_extension; + $mimetype = $this->mimetype; + foreach ($this->sizes as $name => $size) { + if (array_key_exists('extension', $size)) { + $extension = $size['extension']; + } + if (array_key_exists('mimetype', $size)) { + $mimetype = $size['mimetype']; + } + $this->{$name . '_path'} = $this->file_directory . '/' . $this->file_name . '_' . $name . '.' . $extension; + $this->{$name . '_url'} = media_base_url($this->{$name . '_path'}); + $this->{$name . '_mimetype'} = $mimetype; + } + + return true; + } + + public function saveInDisk(File $file, string $dirname, string $filename): void + { + // save original + parent::saveInDisk($file, $dirname, $filename); + + $this->initSizeProperties(); + + // save derived sizes + $imageService = service('image'); + foreach ($this->sizes as $name => $size) { + $pathProperty = $name . '_path'; + $imageService + ->withFile(media_path($this->file_path)) + ->resize($size['width'], $size['height']); + $imageService->save(media_path($this->{$pathProperty})); + } + } + + public function injectFileData(File $file): void + { + $metadata = exif_read_data(media_path($this->file_path), null, true); + + if ($metadata) { + $metadata['sizes'] = $this->sizes; + $this->file_size = $metadata['FILE']['FileSize']; + $this->file_metadata = $metadata; + } + } + + /** + * @param array<string, int[]> $sizes + */ + public function delete(array $sizes): void + { + helper('media'); + + foreach (array_keys($sizes) as $name) { + $pathProperty = $name . '_path'; + unlink(media_path($this->{$pathProperty})); + } + } +} diff --git a/app/Entities/Media.php b/app/Entities/Media.php new file mode 100644 index 0000000000000000000000000000000000000000..b979edbc9c458d12ee167420698197a198a1b205 --- /dev/null +++ b/app/Entities/Media.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use CodeIgniter\Entity\Entity; +use CodeIgniter\Files\File; + +/** + * @property int $id + * @property string $file_path + * @property string $file_directory + * @property string $file_extension + * @property string $file_name + * @property int $file_size + * @property string $file_content_type + * @property array $file_metadata + * @property 'image'|'audio'|'video'|'document' $type + * @property string $description + * @property string|null $language_code + * @property int $uploaded_by + * @property int $updated_by + */ +class Media extends Entity +{ + protected File $file; + + protected string $type = 'document'; + + /** + * @var string[] + */ + protected $dates = ['uploaded_at', 'updated_at', 'deleted_at']; + + /** + * @var array<string, string> + */ + protected $casts = [ + 'id' => 'integer', + 'file_path' => 'string', + 'file_size' => 'int', + 'file_content_type' => 'string', + 'file_metadata' => 'json-array', + 'type' => 'string', + 'description' => 'string', + 'language_code' => '?string', + 'uploaded_by' => 'integer', + 'updated_by' => 'integer', + ]; + + /** + * @param array<string, mixed>|null $data + */ + public function __construct(array $data = null) + { + parent::__construct($data); + + if ($this->file_path) { + [ + 'filename' => $filename, + 'dirname' => $dirname, + 'extension' => $extension, + ] = pathinfo($this->file_path); + + $this->file_name = $filename; + $this->file_directory = $dirname; + $this->file_extension = $extension; + } + } + + public function setFile(File $file): self + { + helper('media'); + + $this->attributes['file_content_type'] = $file->getMimeType(); + $this->attributes['file_metadata'] = json_encode(lstat((string) $file)); + $this->attributes['file_path'] = save_media( + $file, + $this->attributes['file_directory'], + $this->attributes['file_name'] + ); + if ($filesize = filesize(media_path($this->file_path))) { + $this->attributes['file_size'] = $filesize; + } + + return $this; + } +} diff --git a/app/Entities/MediaOLD.php b/app/Entities/MediaOLD.php new file mode 100644 index 0000000000000000000000000000000000000000..d585ad853a16c6475cf265ee55f26bf6e8aa7edb --- /dev/null +++ b/app/Entities/MediaOLD.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use CodeIgniter\Entity\Entity; +use CodeIgniter\Files\File; + +/** + * @property int $id + * @property string $file_path + * @property string $file_directory + * @property string $file_extension + * @property string $file_name + * @property int $file_size + * @property string $file_content_type + * @property array $file_metadata + * @property 'image'|'audio'|'video'|'document' $type + * @property string $description + * @property string|null $language_code + * @property int $uploaded_by + * @property int $updated_by + */ +class Media extends Entity +{ + protected File $file; + + /** + * @var string[] + */ + protected $dates = ['uploaded_at', 'updated_at', 'deleted_at']; + + /** + * @var array<string, string> + */ + protected $casts = [ + 'id' => 'integer', + 'file_path' => 'string', + 'file_size' => 'string', + 'file_content_type' => 'string', + 'file_metadata' => 'json-array', + 'type' => 'string', + 'description' => 'string', + 'language_code' => '?string', + 'uploaded_by' => 'integer', + 'updated_by' => 'integer', + ]; + + public function setFilePath(string $path): self + { + $this->attributes['file_path'] = $path; + + [ + 'filename' => $filename, + 'dirname' => $dirname, + 'extension' => $extension, + ] = pathinfo($path); + + $this->file_name = $filename; + $this->file_directory = $dirname; + $this->file_extension = $extension; + + return $this; + } + + public function saveInDisk(File $file, string $dirname, string $filename): void + { + helper('media'); + + $this->file_content_type = $file->getMimeType(); + + $filePath = save_media($file, $dirname, $filename); + + $this->file_path = $filePath; + } + + public function injectFileData(File $file): void + { + $this->file_content_type = $file->getMimeType(); + $this->type = 'document'; + + if ($filesize = filesize(media_path($this->file_path))) { + $this->file_size = $filesize; + } + } +} diff --git a/app/Entities/Person.php b/app/Entities/Person.php index 3204053a7d49f75862521c6bc90df798a4b08577..10e9fd2273ce56dba44dc993db5fae53f695817c 100644 --- a/app/Entities/Person.php +++ b/app/Entities/Person.php @@ -19,20 +19,15 @@ use RuntimeException; * @property string $full_name * @property string $unique_name * @property string|null $information_url + * @property int $avatar_id * @property Image $avatar - * @property string $avatar_path - * @property string $avatar_mimetype * @property int $created_by * @property int $updated_by * @property object[]|null $roles */ class Person extends Entity { - protected Image $avatar; - - protected ?int $podcast_id = null; - - protected ?int $episode_id = null; + protected ?Image $avatar = null; /** * @var object[]|null @@ -47,8 +42,7 @@ class Person extends Entity 'full_name' => 'string', 'unique_name' => 'string', 'information_url' => '?string', - 'avatar_path' => '?string', - 'avatar_mimetype' => '?string', + 'avatar_id' => '?int', 'podcast_id' => '?integer', 'episode_id' => '?integer', 'created_by' => 'integer', diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php index 16d00f646fb7578bb493ab31bceb4c923e570f6a..1663a6b32fcee5b3ecae96671a7ed21c0d771ed8 100644 --- a/app/Entities/Podcast.php +++ b/app/Entities/Podcast.php @@ -13,6 +13,7 @@ namespace App\Entities; use App\Libraries\SimpleRSSElement; use App\Models\CategoryModel; use App\Models\EpisodeModel; +use App\Models\MediaModel; use App\Models\PersonModel; use App\Models\PlatformModel; use App\Models\UserModel; @@ -34,12 +35,10 @@ use RuntimeException; * @property string|null $description Holds text only description, striped of any markdown or html special characters * @property string $description_markdown * @property string $description_html + * @property int $cover_id * @property Image $cover - * @property string $cover_path - * @property string $cover_mimetype + * @property int|null $banner_id * @property Image|null $banner - * @property string|null $banner_path - * @property string|null $banner_mimetype * @property string $language_code * @property int $category_id * @property Category|null $category @@ -87,9 +86,9 @@ class Podcast extends Entity protected ?Actor $actor = null; - protected Image $cover; + protected ?Image $cover = null; - protected ?Image $banner; + protected ?Image $banner = null; protected ?string $description = null; @@ -150,10 +149,8 @@ class Podcast extends Entity 'title' => 'string', 'description_markdown' => 'string', 'description_html' => 'string', - 'cover_path' => 'string', - 'cover_mimetype' => 'string', - 'banner_path' => '?string', - 'banner_mimetype' => '?string', + 'cover_id' => 'int', + 'banner_id' => '?int', 'language_code' => 'string', 'category_id' => 'integer', 'parental_advisory' => '?string', @@ -195,66 +192,36 @@ class Podcast extends Entity return $this->actor; } - /** - * Saves a podcast cover to the corresponding podcast folder in `public/media/podcast_name/` - */ - public function setCover(Image $cover): static - { - // Save image - $cover->saveImage(config('Images')->podcastCoverSizes, 'podcasts/' . $this->attributes['handle'], 'cover'); - - $this->attributes['cover_path'] = $cover->path; - $this->attributes['cover_mimetype'] = $cover->mimetype; - - return $this; - } - public function getCover(): Image { - return new Image(null, $this->cover_path, $this->cover_mimetype, config('Images')->podcastCoverSizes); - } - - /** - * Saves a podcast cover to the corresponding podcast folder in `public/media/podcast_name/` - */ - public function setBanner(?Image $banner): static - { - if ($banner === null) { - $this->attributes['banner_path'] = null; - $this->attributes['banner_mimetype'] = null; - - return $this; + if (! $this->cover instanceof Image) { + $this->cover = (new MediaModel('image'))->getMediaById($this->cover_id); } - // Save image - $banner->saveImage( - config('Images') - ->podcastBannerSizes, - 'podcasts/' . $this->attributes['handle'], - 'banner' - ); - - $this->attributes['banner_path'] = $banner->path; - $this->attributes['banner_mimetype'] = $banner->mimetype; - - return $this; + return $this->cover; } public function getBanner(): Image { - if ($this->attributes['banner_path'] === null) { - return new Image( - null, - config('Images') + if ($this->banner_id === null) { + return new Image([ + 'file_path' => config('Images') ->podcastBannerDefaultPath, - config('Images') + 'file_mimetype' => config('Images') ->podcastBannerDefaultMimeType, - config('Images') - ->podcastBannerSizes - ); + 'file_size' => 0, + 'file_metadata' => [ + 'sizes' => config('Images') + ->podcastBannerSizes, + ], + ]); + } + + if (! $this->banner instanceof Image) { + $this->banner = (new MediaModel('image'))->getMediaById($this->banner_id); } - return new Image(null, $this->banner_path, $this->banner_mimetype, config('Images') ->podcastBannerSizes); + return $this->banner; } public function getLink(): string diff --git a/app/Helpers/id3_helper.php b/app/Helpers/id3_helper.php index c975f54b8af05c65a1a082482da06db365b73eac..abf89a61437b991dbe4b2d420af1f497e79ed2d2 100644 --- a/app/Helpers/id3_helper.php +++ b/app/Helpers/id3_helper.php @@ -10,29 +10,8 @@ declare(strict_types=1); use App\Entities\Episode; use CodeIgniter\Files\File; -use JamesHeinrich\GetID3\GetID3; use JamesHeinrich\GetID3\WriteTags; -if (! function_exists('get_file_tags')) { - /** - * Gets audio file metadata and ID3 info - * - * @return array<string, string|double|int> - */ - function get_file_tags(File $file): array - { - $getID3 = new GetID3(); - $FileInfo = $getID3->analyze((string) $file); - - return [ - 'filesize' => $FileInfo['filesize'], - 'mime_type' => $FileInfo['mime_type'], - 'avdataoffset' => $FileInfo['avdataoffset'], - 'playtime_seconds' => $FileInfo['playtime_seconds'], - ]; - } -} - if (! function_exists('write_audio_file_tags')) { /** * Write audio file metadata / ID3 tags @@ -45,7 +24,7 @@ if (! function_exists('write_audio_file_tags')) { // Initialize getID3 tag-writing module $tagwriter = new WriteTags(); - $tagwriter->filename = media_path($episode->audio_file_path); + $tagwriter->filename = media_path($episode->audio->file_path); // set various options (optional) $tagwriter->tagformats = ['id3v2.4']; diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index adb7d3bd5de2d95957accb60c75ae0882abf7c5f..fbb845d8f82f47c6fd88ae46f503e92116a30d15 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -211,8 +211,8 @@ if (! function_exists('get_rss_feed')) { ? '' : '?_from=' . urlencode($serviceSlug)), ); - $enclosure->addAttribute('length', (string) $episode->audio_file_size); - $enclosure->addAttribute('type', $episode->audio_file_mimetype); + $enclosure->addAttribute('length', (string) $episode->audio->file_size); + $enclosure->addAttribute('type', $episode->audio->file_content_type); $item->addChild('guid', $episode->guid); $item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123)); @@ -230,7 +230,7 @@ if (! function_exists('get_rss_feed')) { } } $item->addChildWithCDATA('description', $episode->getDescriptionHtml($serviceSlug)); - $item->addChild('duration', (string) $episode->audio_file_duration, $itunesNamespace); + $item->addChild('duration', (string) $episode->audio->duration, $itunesNamespace); $item->addChild('link', $episode->link); $episodeItunesImage = $item->addChild('image', null, $itunesNamespace); $episodeItunesImage->addAttribute('href', $episode->cover->feed_url); @@ -255,7 +255,7 @@ if (! function_exists('get_rss_feed')) { $comments->addAttribute('uri', url_to('episode-comments', $podcast->handle, $episode->slug)); $comments->addAttribute('contentType', 'application/podcast-activity+json'); - if ($episode->transcript_file_url) { + if ($episode->transcript->file_url) { $transcriptElement = $item->addChild('transcript', null, $podcastNamespace); $transcriptElement->addAttribute('url', $episode->transcript_file_url); $transcriptElement->addAttribute( @@ -267,16 +267,17 @@ if (! function_exists('get_rss_feed')) { $transcriptElement->addAttribute('language', $podcast->language_code); } - if ($episode->chapters_file_url) { + if ($episode->chapters->file_url) { $chaptersElement = $item->addChild('chapters', null, $podcastNamespace); $chaptersElement->addAttribute('url', $episode->chapters_file_url); $chaptersElement->addAttribute('type', 'application/json+chapters'); } - foreach ($episode->soundbites as $soundbite) { - $soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace); - $soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time); - $soundbiteElement->addAttribute('duration', (string) $soundbite->duration); + foreach ($episode->clip as $clip) { + // TODO: differentiate video from soundbites? + $soundbiteElement = $item->addChild('soundbite', $clip->label, $podcastNamespace); + $soundbiteElement->addAttribute('start_time', (string) $clip->start_time); + $soundbiteElement->addAttribute('duration', (string) $clip->duration); } foreach ($episode->persons as $person) { diff --git a/app/Helpers/seo_helper.php b/app/Helpers/seo_helper.php index c392d0d582a323eda6c62423d734cc04f8d2bfe7..72b0c790117f1382e5f73dfcc951ceedb80a500c 100644 --- a/app/Helpers/seo_helper.php +++ b/app/Helpers/seo_helper.php @@ -64,9 +64,9 @@ if (! function_exists('get_episode_metatags')) { 'image' => $episode->cover->feed_url, 'description' => $episode->description, 'datePublished' => $episode->published_at->format(DATE_ISO8601), - 'timeRequired' => iso8601_duration($episode->audio_file_duration), + 'timeRequired' => iso8601_duration($episode->audio->duration), 'associatedMedia' => new Thing('MediaObject', [ - 'contentUrl' => $episode->audio_file_url, + 'contentUrl' => $episode->audio->file_url, ]), 'partOfSeries' => new Thing('PodcastSeries', [ 'name' => $episode->podcast->title, @@ -87,7 +87,7 @@ if (! function_exists('get_episode_metatags')) { ->og('image:height', (string) config('Images')->podcastCoverSizes['large']['height']) ->og('locale', $episode->podcast->language_code) ->og('audio', $episode->audio_file_opengraph_url) - ->og('audio:type', $episode->audio_file_mimetype) + ->og('audio:type', $episode->audio->file_content_type) ->meta('article:published_time', $episode->published_at->format(DATE_ISO8601)) ->meta('article:modified_time', $episode->updated_at->format(DATE_ISO8601)) ->twitter('audio:partner', $episode->podcast->publisher ?? '') diff --git a/app/Libraries/MediaClipper/VideoClip.php b/app/Libraries/MediaClipper/VideoClip.php index d1f11de5b97360fbb4bd4f4a5cedaa8a14621169..647e8545c4c0b0953e4432185ad5633f0c9e122f 100644 --- a/app/Libraries/MediaClipper/VideoClip.php +++ b/app/Libraries/MediaClipper/VideoClip.php @@ -79,10 +79,10 @@ class VideoClip helper(['media']); - $this->audioInput = media_path($this->episode->audio_file_path); + $this->audioInput = media_path($this->episode->audio->file_path); $this->episodeCoverPath = media_path($this->episode->cover->path); - if ($this->episode->transcript_file_path !== null) { - $this->subtitlesInput = media_path($this->episode->transcript_file_path); + if ($this->episode->transcript !== null) { + $this->subtitlesInput = media_path($this->episode->transcript->file_path); } $podcastFolder = media_path("podcasts/{$this->episode->podcast->handle}"); @@ -167,7 +167,6 @@ class VideoClip "{$this->videoClipOutput}", ]; - // dd(implode(' ', $videoClipCmd)); return implode(' ', $videoClipCmd); } diff --git a/app/Libraries/PodcastEpisode.php b/app/Libraries/PodcastEpisode.php index 11bc23eb46e1d210519e10308a2ed642e9f48932..5d7aece560d4676b131be8b4ac9a2d4f82382ad6 100644 --- a/app/Libraries/PodcastEpisode.php +++ b/app/Libraries/PodcastEpisode.php @@ -52,24 +52,24 @@ class PodcastEpisode extends ObjectType $this->image = [ 'type' => 'Image', - 'mediaType' => $episode->cover_mimetype, + 'mediaType' => $episode->cover->file_content_type, 'url' => $episode->cover->feed_url, ]; // add audio file $this->audio = [ - 'id' => $episode->audio_file_url, + 'id' => $episode->audio->file_url, 'type' => 'Audio', 'name' => $episode->title, - 'size' => $episode->audio_file_size, - 'duration' => $episode->audio_file_duration, + 'size' => $episode->audio->file_size, + 'duration' => $episode->audio->duration, 'url' => [ - 'href' => $episode->audio_file_url, + 'href' => $episode->audio->file_url, 'type' => 'Link', - 'mediaType' => $episode->audio_file_mimetype, + 'mediaType' => $episode->audio->file_content_type, ], - 'transcript' => $episode->transcript_file_url, - 'chapters' => $episode->chapters_file_url, + 'transcript' => $episode->transcript->file_url, + 'chapters' => $episode->chapters->file_url, ]; $this->comments = url_to('episode-comments', $episode->podcast->handle, $episode->slug); diff --git a/app/Models/SoundbiteModel.php b/app/Models/ClipModel.php similarity index 83% rename from app/Models/SoundbiteModel.php rename to app/Models/ClipModel.php index e17140b0a76cab994549cb74c5b7d1dbde02c13e..6a7b2d8cc5f9588f4560e1f2c97b63325bc5ac80 100644 --- a/app/Models/SoundbiteModel.php +++ b/app/Models/ClipModel.php @@ -12,16 +12,16 @@ declare(strict_types=1); namespace App\Models; -use App\Entities\Soundbite; +use App\Entities\Clip; use CodeIgniter\Database\BaseResult; use CodeIgniter\Model; -class SoundbiteModel extends Model +class ClipsModel extends Model { /** * @var string */ - protected $table = 'soundbites'; + protected $table = 'clips'; /** * @var string @@ -35,6 +35,7 @@ class SoundbiteModel extends Model 'podcast_id', 'episode_id', 'label', + 'type', 'start_time', 'duration', 'created_by', @@ -44,7 +45,7 @@ class SoundbiteModel extends Model /** * @var string */ - protected $returnType = Soundbite::class; + protected $returnType = Clip::class; /** * @var bool @@ -71,23 +72,23 @@ class SoundbiteModel extends Model */ protected $beforeDelete = ['clearCache']; - public function deleteSoundbite(int $podcastId, int $episodeId, int $soundbiteId): BaseResult | bool + public function deleteClip(int $podcastId, int $episodeId, int $clipId): BaseResult | bool { return $this->delete([ 'podcast_id' => $podcastId, 'episode_id' => $episodeId, - 'id' => $soundbiteId, + 'id' => $clipId, ]); } /** - * Gets all soundbites for an episode + * Gets all clips for an episode * - * @return Soundbite[] + * @return Clip[] */ - public function getEpisodeSoundbites(int $podcastId, int $episodeId): array + public function getEpisodeClips(int $podcastId, int $episodeId): array { - $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_soundbites"; + $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_clips"; if (! ($found = cache($cacheName))) { $found = $this->where([ 'episode_id' => $episodeId, @@ -114,7 +115,7 @@ class SoundbiteModel extends Model ); cache() - ->delete("podcast#{$episode->podcast_id}_episode#{$episode->id}_soundbites"); + ->delete("podcast#{$episode->podcast_id}_episode#{$episode->id}_clips"); // delete cache for rss feed cache() diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index c73886f32f2052ef11dc280ead27c0fd4348d794..043e4fb697238f11300723bca3160727d587691f 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -68,18 +68,13 @@ class EpisodeModel extends Model 'guid', 'title', 'slug', - 'audio_file_path', - 'audio_file_duration', - 'audio_file_mimetype', - 'audio_file_size', - 'audio_file_header_size', + 'audio_file_id', 'description_markdown', 'description_html', - 'cover_path', - 'cover_mimetype', - 'transcript_file_path', + 'cover_id', + 'transcript_file_id', 'transcript_file_remote_url', - 'chapters_file_path', + 'chapters_file_id', 'chapters_file_remote_url', 'parental_advisory', 'number', @@ -119,7 +114,7 @@ class EpisodeModel extends Model 'podcast_id' => 'required', 'title' => 'required', 'slug' => 'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]', - 'audio_file_path' => 'required', + 'audio_file_id' => 'required', 'description_markdown' => 'required', 'number' => 'is_natural_no_zero|permit_empty', 'season_number' => 'is_natural_no_zero|permit_empty', diff --git a/app/Models/MediaModel.php b/app/Models/MediaModel.php new file mode 100644 index 0000000000000000000000000000000000000000..6d760524daadf65c7593073117504d7656291412 --- /dev/null +++ b/app/Models/MediaModel.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Models; + +use App\Entities\Audio; +use App\Entities\Image; +use App\Entities\Media; +use CodeIgniter\Database\ConnectionInterface; +use CodeIgniter\Model; +use CodeIgniter\Validation\ValidationInterface; + +class MediaModel extends Model +{ + /** + * @var string + */ + protected $table = 'media'; + + /** + * @var string + */ + protected $returnType = Media::class; + + /** + * @var string[] + */ + protected $allowedFields = [ + 'id', + 'file_path', + 'file_size', + 'file_content_type', + 'file_metadata', + 'type', + 'description', + 'language_code', + 'uploaded_by', + 'updated_by', + ]; + + /** + * Model constructor. + * + * @param ConnectionInterface|null $db DB Connection + * @param ValidationInterface|null $validation Validation + */ + public function __construct( + protected string $fileType, + ConnectionInterface &$db = null, + ValidationInterface $validation = null + ) { + switch ($fileType) { + case 'audio': + $this->returnType = Audio::class; + break; + case 'image': + $this->returnType = Image::class; + break; + default: + // do nothing, keep Media class as default + break; + } + + parent::__construct($db, $validation); + } + + /** + * @return Media|Image|Audio + */ + public function getMediaById(int $mediaId): object + { + $cacheName = "media#{$mediaId}"; + if (! ($found = cache($cacheName))) { + $builder = $this->where([ + 'id' => $mediaId, + ]); + + $result = $builder->first(); + $mediaClass = $this->returnType; + $found = new $mediaClass($result->toArray(false, true)); + + cache() + ->save($cacheName, $found, DECADE); + } + + return $found; + } + + /** + * @param Media|Image|Audio $media + */ + public function saveMedia(object $media): int | false + { + // insert record in database + if (! $mediaId = $this->insert($media, true)) { + return false; + } + + // @phpstan-ignore-next-line + return $mediaId; + } +} diff --git a/app/Models/MediaModelOLD.php b/app/Models/MediaModelOLD.php new file mode 100644 index 0000000000000000000000000000000000000000..fcdc566006a3a3576c2f2d5a997aa2bc5d3dd04e --- /dev/null +++ b/app/Models/MediaModelOLD.php @@ -0,0 +1,112 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Models; + +use App\Entities\Audio; +use App\Entities\Image; +use App\Entities\Media; +use CodeIgniter\Database\ConnectionInterface; +use CodeIgniter\Model; +use CodeIgniter\Validation\ValidationInterface; + +class MediaModel extends Model +{ + /** + * @var string + */ + protected $table = 'media'; + + /** + * @var string + */ + protected $returnType = Media::class; + + /** + * @var string[] + */ + protected $allowedFields = [ + 'id', + 'file_path', + 'file_size', + 'file_content_type', + 'file_metadata', + 'type', + 'description', + 'language_code', + 'uploaded_by', + 'updated_by', + ]; + + /** + * Model constructor. + * + * @param ConnectionInterface|null $db DB Connection + * @param ValidationInterface|null $validation Validation + */ + public function __construct( + protected string $fileType, + ConnectionInterface &$db = null, + ValidationInterface $validation = null + ) { + switch ($fileType) { + case 'audio': + $this->returnType = Audio::class; + break; + case 'image': + $this->returnType = Image::class; + break; + default: + // do nothing, keep Media class as default + break; + } + + parent::__construct($db, $validation); + } + + /** + * @return Media|Image|Audio + */ + public function getMediaById(int $mediaId): object + { + $cacheName = "media#{$mediaId}"; + if (! ($found = cache($cacheName))) { + $builder = $this->where([ + 'id' => $mediaId, + ]); + + $found = $builder->first(); + + cache() + ->save($cacheName, $found, DECADE); + } + + return $found; + } + + /** + * @param Media|Image $media + */ + public function saveMedia(object $media): int | false + { + // insert record in database + if (! $mediaId = $this->insert($media, true)) { + return false; + } + + // @phpstan-ignore-next-line + return $mediaId; + } + + public function deleteFile(int $mediaId): void + { + // TODO: get file, delete it from disk & from database + } +} diff --git a/app/Models/PersonModel.php b/app/Models/PersonModel.php index 77cc85c2fe64bcb81ac05fbe3c4a6ef0208be911..9e5688825e8065bb8524271734ef86041c8fc5e5 100644 --- a/app/Models/PersonModel.php +++ b/app/Models/PersonModel.php @@ -35,8 +35,7 @@ class PersonModel extends Model 'full_name', 'unique_name', 'information_url', - 'avatar_path', - 'avatar_mimetype', + 'avatar_id', 'created_by', 'updated_by', ]; diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index aef6207c39cff77a54381f35717f35a6a78b2891..02a913bb2448c2049a757113a8da3876706a8bad 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -40,10 +40,8 @@ class PodcastModel extends Model 'description_html', 'episode_description_footer_markdown', 'episode_description_footer_html', - 'cover_path', - 'cover_mimetype', - 'banner_path', - 'banner_mimetype', + 'cover_id', + 'banner_id', 'language_code', 'category_id', 'parental_advisory', @@ -92,7 +90,7 @@ class PodcastModel extends Model 'handle' => 'required|regex_match[/^[a-zA-Z0-9\_]{1,32}$/]|is_unique[podcasts.handle,id,{id}]', 'description_markdown' => 'required', - 'cover_path' => 'required', + 'cover_id' => 'required', 'language_code' => 'required', 'category_id' => 'required', 'owner_email' => 'required|valid_email', @@ -460,7 +458,7 @@ class PodcastModel extends Model if ($podcastActor) { $podcastActor->avatar_image_url = $podcast->cover->thumbnail_url; - $podcastActor->avatar_image_mimetype = $podcast->cover_mimetype; + $podcastActor->avatar_image_mimetype = $podcast->cover->thumbnail_mimetype; (new ActorModel())->update($podcast->actor_id, $podcastActor); } diff --git a/app/Resources/js/modules/EpisodesMap.ts b/app/Resources/js/modules/EpisodesMap.ts index 23ae543bcded8117acc67b95ef96ec8add5ccf18..a90e558532fada1aaafda88998d22bf3f5beae45 100644 --- a/app/Resources/js/modules/EpisodesMap.ts +++ b/app/Resources/js/modules/EpisodesMap.ts @@ -47,7 +47,7 @@ const drawEpisodesMap = async (mapDivId: string, dataUrl: string) => { data[i].longitude, ]).bindPopup( '<div class="flex min-w-max w-full gap-x-2"><img src="' + - data[i].cover_path + + data[i].cover_url + '" alt="' + data[i].episode_title + '" class="rounded w-16 h-16" /><div class="flex flex-col flex-1"><h2 class="leading-tight text-sm w-56 line-clamp-2 font-bold"><a href="' + diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php index cd33cc4b213deb35083f1dcd4f017f9f6d59685d..b0847e1c9d1742f620638f791a875aca82eb9fdf 100644 --- a/modules/Admin/Controllers/EpisodeController.php +++ b/modules/Admin/Controllers/EpisodeController.php @@ -14,13 +14,15 @@ use App\Entities\Episode; use App\Entities\EpisodeComment; use App\Entities\Image; use App\Entities\Location; +use App\Entities\Media; use App\Entities\Podcast; use App\Entities\Post; +use App\Models\ClipsModel; use App\Models\EpisodeCommentModel; use App\Models\EpisodeModel; +use App\Models\MediaModel; use App\Models\PodcastModel; use App\Models\PostModel; -use App\Models\SoundbiteModel; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\I18n\Time; @@ -156,9 +158,30 @@ class EpisodeController extends BaseController 'published_at' => null, ]); + $db = db_connect(); + $db->transStart(); + $coverFile = $this->request->getFile('cover'); if ($coverFile !== null && $coverFile->isValid()) { - $newEpisode->cover = new Image($coverFile); + $cover = new Image([ + 'file_name' => $newEpisode->slug, + 'file_directory' => 'podcasts/' . $this->podcast->handle, + 'sizes' => config('Images') + ->podcastBannerSizes, + 'file' => $this->request->getFile('banner'), + 'uploaded_by' => user_id(), + 'updated_by' => user_id(), + ]); + $mediaModel = new MediaModel('image'); + if (! ($newCoverId = $mediaModel->saveMedia($cover))) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $mediaModel->errors()); + } + + $newEpisode->cover_id = $newCoverId; } $transcriptChoice = $this->request->getPost('transcript-choice'); @@ -167,10 +190,26 @@ class EpisodeController extends BaseController && ($transcriptFile = $this->request->getFile('transcript_file')) && $transcriptFile->isValid() ) { - $newEpisode->transcript_file = $transcriptFile; + $transcript = new Media([ + 'file_name' => $newEpisode->slug . '-transcript', + 'file_directory' => 'podcasts/' . $this->podcast->handle, + 'file' => $transcriptFile, + 'uploaded_by' => user_id(), + 'updated_by' => user_id(), + ]); + $mediaModel = new MediaModel('image'); + if (! ($newTranscriptId = $mediaModel->saveMedia($transcript))) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $mediaModel->errors()); + } + + $newEpisode->transcript_id = $newTranscriptId; } elseif ($transcriptChoice === 'remote-url') { - $newEpisode->transcript_file_remote_url = $this->request->getPost( - 'transcript_file_remote_url' + $newEpisode->transcript_remote_url = $this->request->getPost( + 'transcript_remote_url' ) === '' ? null : $this->request->getPost('transcript_file_remote_url'); } @@ -813,11 +852,11 @@ class EpisodeController extends BaseController return redirect()->route('soundbites-edit', [$this->podcast->id, $this->episode->id]); } - public function soundbiteDelete(string $soundbiteId): RedirectResponse + public function soundbiteDelete(string $clipId): RedirectResponse { - (new SoundbiteModel())->deleteSoundbite($this->podcast->id, $this->episode->id, (int) $soundbiteId); + (new ClipsModel())->deleteClip($this->podcast->id, $this->episode->id, (int) $clipId); - return redirect()->route('soundbites-edit', [$this->podcast->id, $this->episode->id]); + return redirect()->route('clips-edit', [$this->podcast->id, $this->episode->id]); } public function embed(): string diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php index d2cae11f7334df61660371f7981531e9c22d0fc7..e3e0162e600a8be54aa9b94fb326e0818e80614d 100644 --- a/modules/Admin/Controllers/PodcastController.php +++ b/modules/Admin/Controllers/PodcastController.php @@ -16,6 +16,7 @@ use App\Entities\Podcast; use App\Models\CategoryModel; use App\Models\EpisodeModel; use App\Models\LanguageModel; +use App\Models\MediaModel; use App\Models\PodcastModel; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\RedirectResponse; @@ -192,11 +193,10 @@ class PodcastController extends BaseController $partnerImageUrl = null; } - $podcast = new Podcast([ + $newPodcast = new Podcast([ 'title' => $this->request->getPost('title'), 'handle' => $this->request->getPost('handle'), 'description_markdown' => $this->request->getPost('description'), - 'cover' => new Image($this->request->getFile('cover')), 'language_code' => $this->request->getPost('language'), 'category_id' => $this->request->getPost('category'), 'parental_advisory' => @@ -225,17 +225,53 @@ class PodcastController extends BaseController 'updated_by' => user_id(), ]); + $db = db_connect(); + $db->transStart(); + + $cover = new Image([ + 'file_name' => 'cover', + 'file_directory' => 'podcasts/' . $newPodcast->handle, + 'sizes' => config('Images') + ->podcastCoverSizes, + 'file' => $this->request->getFile('cover'), + 'uploaded_by' => user_id(), + 'updated_by' => user_id(), + ]); + $mediaModel = new MediaModel('image'); + if (! ($newCoverId = $mediaModel->saveMedia($cover))) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $mediaModel->errors()); + } + $newPodcast->cover_id = $newCoverId; + $bannerFile = $this->request->getFile('banner'); if ($bannerFile !== null && $bannerFile->isValid()) { - $podcast->banner = new Image($bannerFile); + $banner = new Image([ + 'file_name' => 'banner', + 'file_directory' => 'podcasts/' . $newPodcast->handle, + 'sizes' => config('Images') + ->podcastBannerSizes, + 'file' => $this->request->getFile('banner'), + 'uploaded_by' => user_id(), + 'updated_by' => user_id(), + ]); + $mediaModel = new MediaModel('image'); + if (! ($newBannerId = $mediaModel->saveMedia($banner))) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $mediaModel->errors()); + } + + $newPodcast->banner_id = $newBannerId; } $podcastModel = new PodcastModel(); - $db = db_connect(); - - $db->transStart(); - - if (! ($newPodcastId = $podcastModel->insert($podcast, true))) { + if (! ($newPodcastId = $podcastModel->insert($newPodcast, true))) { $db->transRollback(); return redirect() ->back() @@ -311,7 +347,7 @@ class PodcastController extends BaseController $coverFile = $this->request->getFile('cover'); if ($coverFile !== null && $coverFile->isValid()) { - $this->podcast->cover = new Image($coverFile); + $this->podcast->cover->setFile($coverFile); } $bannerFile = $this->request->getFile('banner'); if ($bannerFile !== null && $bannerFile->isValid()) { diff --git a/modules/Fediverse/Controllers/SchedulerController.php b/modules/Fediverse/Controllers/SchedulerController.php index 9331d77b8f6e65d3243c5740684954d9f46985ba..25e840d886916c0a201955c0842d734a8f30ad71 100644 --- a/modules/Fediverse/Controllers/SchedulerController.php +++ b/modules/Fediverse/Controllers/SchedulerController.php @@ -36,7 +36,7 @@ class SchedulerController extends Controller // set activity post to delivered model('ActivityModel') ->update($scheduledActivity->id, [ - 'task_status' => 'delivered', + 'status' => 'delivered', ]); } } diff --git a/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php index f9bec2bf5966fb582c8fae40ca36dc5029821ee5..c50c46a741b3c635e034a25ad36652361dc3c3be 100644 --- a/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php +++ b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php @@ -44,7 +44,7 @@ class AddActivities extends Migration 'payload' => [ 'type' => 'JSON', ], - 'task_status' => [ + 'status' => [ 'type' => 'ENUM', 'constraint' => ['queued', 'delivered'], 'null' => true, diff --git a/modules/Fediverse/Entities/Activity.php b/modules/Fediverse/Entities/Activity.php index 458f97b777edcf7f5a54bc1910f9c91b6438397b..52f23fff342786aff9266765ef1779530df4c48e 100644 --- a/modules/Fediverse/Entities/Activity.php +++ b/modules/Fediverse/Entities/Activity.php @@ -23,7 +23,7 @@ use RuntimeException; * @property Post $post * @property string $type * @property object $payload - * @property string|null $task_status + * @property string|null $status * @property Time|null $scheduled_at * @property Time $created_at */ @@ -55,7 +55,7 @@ class Activity extends UuidEntity 'post_id' => '?string', 'type' => 'string', 'payload' => 'json', - 'task_status' => '?string', + 'status' => '?string', ]; public function getActor(): Actor diff --git a/modules/Fediverse/Models/ActivityModel.php b/modules/Fediverse/Models/ActivityModel.php index 09428848cd9a70a30ee1e910126e9b5bd3448b7e..542f81f9f721969f0829ed8045e1286565449f82 100644 --- a/modules/Fediverse/Models/ActivityModel.php +++ b/modules/Fediverse/Models/ActivityModel.php @@ -42,7 +42,7 @@ class ActivityModel extends BaseUuidModel 'post_id', 'type', 'payload', - 'task_status', + 'status', 'scheduled_at', ]; @@ -100,7 +100,7 @@ class ActivityModel extends BaseUuidModel 'type' => $type, 'payload' => $payload, 'scheduled_at' => $scheduledAt, - 'task_status' => $taskStatus, + 'status' => $taskStatus, ], true, ); @@ -112,7 +112,7 @@ class ActivityModel extends BaseUuidModel public function getScheduledActivities(): array { return $this->where('`scheduled_at` <= NOW()', null, false) - ->where('task_status', 'queued') + ->where('status', 'queued') ->orderBy('scheduled_at', 'ASC') ->findAll(); } diff --git a/public/.htaccess b/public/.htaccess index a5d6c2a541286ca75c7d65fb6ac5944dfd123bd6..189ec9ae332bb964dcb56a626c3730be95561f7d 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -11,7 +11,7 @@ Options All -Indexes Options +FollowSymlinks RewriteEngine On - # If you installed CodeIgniter in a subfolder, you will need to + # If you installed Castopod Host in a subfolder, you will need to # change the following line to match the subfolder you need. # http://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewritebase # RewriteBase / diff --git a/themes/cp_admin/episode/list.php b/themes/cp_admin/episode/list.php index 8e1e96a458c466b33f1f7483e3f0017b3eb06e02..3ca83c14d4b91e692798ed147f119eeba5d3a328 100644 --- a/themes/cp_admin/episode/list.php +++ b/themes/cp_admin/episode/list.php @@ -29,9 +29,9 @@ 'cell' => function ($episode, $podcast) { 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<?= $episode->audio_file_duration ?>S">' . + '<time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/50" datetime="PT<?= $episode->audio->duration ?>S">' . format_duration( - $episode->audio_file_duration, + $episode->audio->duration, ) . '</time>' . '<img loading="lazy" src="' . $episode->cover->thumbnail_url . '" alt="' . $episode->title . '" class="object-cover w-20 rounded-lg shadow-inner aspect-square" />' . diff --git a/themes/cp_admin/episode/publish.php b/themes/cp_admin/episode/publish.php index 48b2cc7d14ecc4f98d75c93d731babfe391b0ad7..f3e99324103a7815847d3418621d05819aafe655 100644 --- a/themes/cp_admin/episode/publish.php +++ b/themes/cp_admin/episode/publish.php @@ -54,12 +54,12 @@ ) ?> </div> <div class="text-xs text-skin-muted"> - <time datetime="PT<?= $episode->audio_file_duration ?>S"> - <?= format_duration($episode->audio_file_duration) ?> + <time datetime="PT<?= $episode->audio->duration ?>S"> + <?= format_duration($episode->audio->duration) ?> </time> </div> </a> - <?= audio_player($episode->audio_file_url, $episode->audio_file_mimetype, 'mt-auto') ?> + <?= audio_player($episode->audio->file_url, $episode->audio->file_content_type, 'mt-auto') ?> </div> </div> <footer class="flex justify-around px-6 py-3"> diff --git a/themes/cp_admin/episode/publish_edit.php b/themes/cp_admin/episode/publish_edit.php index 2df15f592c42f17b54d1f6cd7052857baf28ffc2..8eb15701c172c2d74745b2ab1a19d2e8ffded5a4 100644 --- a/themes/cp_admin/episode/publish_edit.php +++ b/themes/cp_admin/episode/publish_edit.php @@ -58,12 +58,12 @@ <div class="text-xs text-skin-muted"> <?= relative_time($episode->published_at) ?> <span class="mx-1">•</span> - <time datetime="PT<?= $episode->audio_file_duration ?>S"> - <?= format_duration($episode->audio_file_duration) ?> + <time datetime="PT<?= $episode->audio->duration ?>S"> + <?= format_duration($episode->audio->duration) ?> </time> </div> </a> - <?= audio_player($episode->audio_file_url, $episode->audio_file_mimetype, 'mt-auto') ?> + <?= audio_player($episode->audio->file_url, $episode->audio->file_content_type, 'mt-auto') ?> </div> </div> <footer class="flex justify-around px-6 py-3"> diff --git a/themes/cp_admin/episode/soundbites.php b/themes/cp_admin/episode/soundbites.php index 1555d1474d09b6fcd93bcb0b6a600bc32ee6984f..425c989962ac9314601faeb0bb35c4d4ddbeae65 100644 --- a/themes/cp_admin/episode/soundbites.php +++ b/themes/cp_admin/episode/soundbites.php @@ -35,8 +35,8 @@ foreach ($episode->soundbites as $soundbite) { $table->addRow( - "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[{$soundbite->id}][start_time]' value='{$soundbite->start_time}' data-type='soundbite-field' data-field-type='start_time' required='true' />", - "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[{$soundbite->id}][duration]' value='{$soundbite->duration}' data-type='soundbite-field' data-field-type='duration' required='true' />", + "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[{$soundbite->id}][start_time]' value='{$soundbite->start_time}' data-type='soundbite-field' data-field-type='start_time' required='true' />", + "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[{$soundbite->id}][duration]' value='{$soundbite->duration}' data-type='soundbite-field' data-field-type='duration' required='true' />", "<Forms.Input class='flex-1' name='soundbites[{$soundbite->id}][label]' value='{$soundbite->label}' />", "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='{$soundbite->id}'>" . lang('Episode.soundbites_form.play') . '</IconButton>', '<IconButton uri=' . route_to( @@ -49,8 +49,8 @@ } $table->addRow( - "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[0][start_time]' data-type='soundbite-field' data-field-type='start_time' />", - "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[0][duration]' data-type='soundbite-field' data-field-type='duration' />", + "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[0][start_time]' data-type='soundbite-field' data-field-type='start_time' />", + "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[0][duration]' data-type='soundbite-field' data-field-type='duration' />", "<Forms.Input class='flex-1' name='soundbites[0][label]' />", "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='0'>" . lang('Episode.soundbites_form.play') . '</IconButton>', ); @@ -61,7 +61,7 @@ <div class="flex items-center gap-x-2"> <audio controls preload="auto" class="flex-1 w-full"> - <source src="<?= $episode->audio_file_url ?>" type="<?= $episode->audio_file_mimetype ?>"> + <source src="<?= $episode->audio->file_url ?>" type="<?= $episode->audio->file_content_type ?>"> Your browser does not support the audio tag. </audio> <IconButton glyph="timer" variant="info" data-type="get-soundbite" data-start-time-field-name="soundbites[0][start_time]" data-duration-field-name="soundbites[0][duration]" ><?= lang('Episode.soundbites_form.bookmark') ?></IconButton> diff --git a/themes/cp_admin/episode/view.php b/themes/cp_admin/episode/view.php index 394b4211552dd33c0b079c654be0a519457a0b88..5310bce7665f0fbad5a2ab392fa454e7cea0e1bb 100644 --- a/themes/cp_admin/episode/view.php +++ b/themes/cp_admin/episode/view.php @@ -28,7 +28,7 @@ <?= $this->section('content') ?> <div class="mb-12"> - <?= audio_player($episode->audio_file_url, $episode->audio_file_mimetype) ?> + <?= audio_player($episode->audio->file_url, $episode->audio->file_content_type) ?> </div> <div class="grid grid-cols-1 gap-4 lg:grid-cols-2"> diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php index 94befb4d0153b6a4d3e8e05379c6daf6f7a9e480..45651946c36ae68d9fa9cbc2ae20a70c9a9860ad 100644 --- a/themes/cp_admin/podcast/edit.php +++ b/themes/cp_admin/podcast/edit.php @@ -22,7 +22,7 @@ <?= csrf_field() ?> <div class="sticky z-40 flex flex-col w-full max-w-xs overflow-hidden shadow-sm bg-elevated border-3 border-subtle top-24 rounded-xl"> - <?php if ($podcast->banner_path !== null): ?> + <?php if ($podcast->banner_id !== null): ?> <a href="<?= route_to('podcast-banner-delete', $podcast->id) ?>" class="absolute p-1 text-red-700 bg-red-100 border-2 rounded-full hover:text-red-900 border-contrast focus:ring-accent top-2 right-2" title="<?= lang('Podcast.form.banner_delete') ?>" data-tooltip="bottom"><?= icon('delete-bin') ?></a> <?php endif; ?> <img src="<?= $podcast->banner->small_url ?>" alt="" class="object-cover w-full aspect-[3/1] bg-header" /> diff --git a/themes/cp_app/embed.php b/themes/cp_app/embed.php index 698a63cc33d276796bfa4717dc54d57b42b4b9a4..7b49fae61c1f15973018f49cbf77eb0ab49ae694 100644 --- a/themes/cp_app/embed.php +++ b/themes/cp_app/embed.php @@ -41,12 +41,12 @@ style="--vm-player-box-shadow:0; --vm-player-theme: hsl(var(--color-accent-base)); --vm-control-focus-color: hsl(var(--color-accent-contrast)); --vm-control-spacing: 4px; --vm-menu-item-focus-bg: hsl(var(--color-background-highlight)); --vm-control-icon-size: 24px; <?= str_ends_with($theme, 'transparent') ? '--vm-controls-bg: transparent;' : '' ?>" > <vm-audio preload="none"> - <?php $source = logged_in() ? $episode->audio_file_url : $episode->audio_file_analytics_url . + <?php $source = logged_in() ? $episode->audio->file_url : $episode->audio_file_analytics_url . (isset($_SERVER['HTTP_REFERER']) ? '?_from=' . parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST) : '') ?> - <source src="<?= $source ?>" type="<?= $episode->audio_file_mimetype ?>" /> + <source src="<?= $source ?>" type="<?= $episode->audio->file_content_type ?>" /> </vm-audio> <vm-ui> <vm-icon-library name="castopod-icons"></vm-icon-library> diff --git a/themes/cp_app/episode/_layout.php b/themes/cp_app/episode/_layout.php index 0276b40ef2ee98e250b5635bee2eb1f3581a9344..80cb7e1350f6dd83f22a93b8bc81d322a95f3b18 100644 --- a/themes/cp_app/episode/_layout.php +++ b/themes/cp_app/episode/_layout.php @@ -115,14 +115,14 @@ title="<?= $episode->title ?>" podcast="<?= $episode->podcast->title ?>" src="<?= $episode->audio_file_web_url ?>" - mediaType="<?= $episode->audio_file_mimetype ?>" + mediaType="<?= $episode->audio->file_content_type ?>" playLabel="<?= lang('Common.play_episode_button.play') ?>" playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button> <div class="text-xs"> <?= relative_time($episode->published_at) ?> <span class="mx-1">•</span> - <time datetime="PT<?= $episode->audio_file_duration ?>S"> - <?= format_duration_symbol($episode->audio_file_duration) ?> + <time datetime="PT<?= $episode->audio->duration ?>S"> + <?= format_duration_symbol($episode->audio->duration) ?> </time> </div> </div> diff --git a/themes/cp_app/episode/_partials/card.php b/themes/cp_app/episode/_partials/card.php index df1e327c6dc49cf5d6f7320d1b3086baeaa3e070..1105015ddde91511dcfee6662bc88aa609c8c72c 100644 --- a/themes/cp_app/episode/_partials/card.php +++ b/themes/cp_app/episode/_partials/card.php @@ -1,7 +1,7 @@ <article class="flex w-full p-4 shadow bg-elevated rounded-conditional-2xl gap-x-2"> <div class="relative"> - <time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/75" datetime="PT<?= $episode->audio_file_duration ?>S"> - <?= format_duration($episode->audio_file_duration) ?> + <time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/75" datetime="PT<?= $episode->audio->duration ?>S"> + <?= format_duration($episode->audio->duration) ?> </time> <img loading="lazy" src="<?= $episode->cover ->thumbnail_url ?>" alt="<?= $episode->title ?>" class="object-cover w-20 rounded-lg shadow-inner aspect-square" /> @@ -20,7 +20,7 @@ title="<?= $episode->title ?>" podcast="<?= $episode->podcast->title ?>" src="<?= $episode->audio_file_web_url ?>" - mediaType="<?= $episode->audio_file_mimetype ?>" + mediaType="<?= $episode->audio->file_content_type ?>" playLabel="<?= lang('Common.play_episode_button.play') ?>" playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button> </div> diff --git a/themes/cp_app/episode/_partials/preview_card.php b/themes/cp_app/episode/_partials/preview_card.php index a3248251779e802c83ca1349b692716be23d2f6e..d9b97d8043723f0faab6135a72be63b5a93575ee 100644 --- a/themes/cp_app/episode/_partials/preview_card.php +++ b/themes/cp_app/episode/_partials/preview_card.php @@ -1,7 +1,7 @@ <div class="flex items-center border-y border-subtle"> <div class="relative"> - <time class="absolute px-1 text-sm font-semibold text-white rounded bg-black/75 bottom-2 right-2" datetime="PT<?= $episode->audio_file_duration ?>S"> - <?= format_duration($episode->audio_file_duration) ?> + <time class="absolute px-1 text-sm font-semibold text-white rounded bg-black/75 bottom-2 right-2" datetime="PT<?= $episode->audio->duration ?>S"> + <?= format_duration($episode->audio->duration) ?> </time> <img src="<?= $episode->cover->thumbnail_url ?>" @@ -21,7 +21,7 @@ title="<?= $episode->title ?>" podcast="<?= $episode->podcast->title ?>" src="<?= $episode->audio_file_web_url ?>" - mediaType="<?= $episode->audio_file_mimetype ?>" + mediaType="<?= $episode->audio->file_content_type ?>" playLabel="<?= lang('Common.play_episode_button.play') ?>" playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button> </div> \ No newline at end of file