feat: add media entity and link documents, images and audio files to it

This commit is contained in:
Yassine Doghri 2021-12-14 16:41:10 +00:00
parent 1d1490b06a
commit 6ecf2866cf
45 changed files with 1034 additions and 564 deletions

View File

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

View File

@ -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,
];

View File

@ -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');
}
}

View File

@ -46,25 +46,13 @@ class AddPodcasts extends Migration
'description_html' => [
'type' => 'TEXT',
],
'cover_path' => [
'type' => 'VARCHAR',
'constraint' => 255,
'cover_id' => [
'type' => 'INT',
'unsigned' => 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,
],
'banner_path' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
'default' => null,
],
'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');

View File

@ -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' => [
'audio_id' => [
'type' => 'INT',
'unsigned' => true,
'comment' => 'File size in bytes',
],
'audio_file_header_size' => [
'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,
'cover_id' => [
'type' => 'INT',
'unsigned' => true,
'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,
'transcript_id' => [
'type' => 'INT',
'unsigned' => true,
'null' => true,
],
'transcript_file_path' => [
'type' => 'VARCHAR',
'constraint' => 255,
'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');

View File

@ -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');

View File

@ -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');
}
}

51
app/Entities/Audio.php Normal file
View File

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

View File

@ -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',
];

View File

@ -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
public function getAudio(): Audio
{
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
{
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');
return new File(media_path($this->audio_file_path));
}
public function getTranscriptFile(): ?File
{
if ($this->attributes['transcript_file_path']) {
helper('media');
return new File(media_path($this->attributes['transcript_file_path']));
if (! $this->audio) {
$this->audio = (new MediaModel('audio'))->getMediaById($this->audio_id);
}
return null;
return $this->audio;
}
public function getChaptersFile(): ?File
public function getTranscript(): ?Media
{
if ($this->attributes['chapters_file_path']) {
helper('media');
return new File(media_path($this->attributes['chapters_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(): ?Media
{
if ($this->chapters_id !== null && $this->chapters === null) {
$this->chapters = (new MediaModel('document'))->getMediaById($this->chapters_id);
}
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 episodes soundbites
* Returns the episodes 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;
}
/**

View File

@ -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.');
}
parent::__construct($data);
$dirname = '';
$filename = '';
$extension = '';
if ($file !== null) {
$dirname = $file->getPath();
$filename = $file->getBasename();
$extension = $file->getExtension();
$mimetype = $file->getMimeType();
}
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)
{
// 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;
$extension = '.' . $this->extension;
$mimetype = $this->mimetype;
if ($fileSuffix !== '') {
$sizeName = substr($fileSuffix, 1);
if (array_key_exists('extension', $this->sizes[$sizeName])) {
$extension = '.' . $this->sizes[$sizeName]['extension'];
}
if (array_key_exists('mimetype', $this->sizes[$sizeName])) {
$mimetype = $this->sizes[$sizeName]['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;
if ($this->file_path && $this->file_metadata) {
$this->sizes = $this->file_metadata['sizes'];
$this->initSizeProperties();
}
}
public function getMimetype(): string
{
return $this->mimetype;
}
public function getFile(): File
{
return $this->file;
}
/**
* @param array<string, array<string, int|string>> $sizes
*/
public function saveImage(array $sizes, string $dirname, string $filename): void
public function initSizeProperties(): bool
{
helper('media');
$this->dirname = $dirname;
$this->filename = $filename;
$this->sizes = $sizes;
$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;
}
save_media($this->file, $this->dirname, $this->filename);
return true;
}
public function setFile(File $file): self
{
parent::setFile($file);
$metadata = exif_read_data(media_path($this->file_path), null, true);
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;
}
}

123
app/Entities/ImageOLD.php Normal file
View File

@ -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}));
}
}
}

95
app/Entities/Media.php Normal file
View File

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

93
app/Entities/MediaOLD.php Normal file
View File

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

View File

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

View File

@ -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,
],
]);
}
return new Image(null, $this->banner_path, $this->banner_mimetype, config('Images') ->podcastBannerSizes);
if (! $this->banner instanceof Image) {
$this->banner = (new MediaModel('image'))->getMediaById($this->banner_id);
}
return $this->banner;
}
public function getLink(): string

View File

@ -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'];

View File

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

View File

@ -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 ?? '')

View File

@ -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);
}

View File

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

View File

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

View File

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

109
app/Models/MediaModel.php Normal file
View File

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

View File

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

View File

@ -35,8 +35,7 @@ class PersonModel extends Model
'full_name',
'unique_name',
'information_url',
'avatar_path',
'avatar_mimetype',
'avatar_id',
'created_by',
'updated_by',
];

View File

@ -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);
}

View File

@ -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="' +

View File

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

View File

@ -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()) {

View File

@ -36,7 +36,7 @@ class SchedulerController extends Controller
// set activity post to delivered
model('ActivityModel')
->update($scheduledActivity->id, [
'task_status' => 'delivered',
'status' => 'delivered',
]);
}
}

View File

@ -44,7 +44,7 @@ class AddActivities extends Migration
'payload' => [
'type' => 'JSON',
],
'task_status' => [
'status' => [
'type' => 'ENUM',
'constraint' => ['queued', 'delivered'],
'null' => true,

View File

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

View File

@ -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();
}

View File

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

View File

@ -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" />' .

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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