diff --git a/UPDATE.md b/UPDATE.md index fe8923ae62f1df7a40c18e59edfc2139f7c711e7..1185b00b58d6e393a2100519ae53c4f7234f0faa 100644 --- a/UPDATE.md +++ b/UPDATE.md @@ -59,7 +59,7 @@ performance improvements âš¡. ### Where can I find my _Castopod Host_ version? Go to your _Castopod Host_ admin panel, the version is displayed on the bottom -right corner. +left corner. Alternatively, you can find the version in the `app > Config > Constants.php` file. diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 57c4e672b8381cd6759e7626d31c06b651265758..80286775bf7f0262be4d2c59801af572bc27fe6e 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -275,7 +275,7 @@ class Episode extends Entity ]); $transcript->setFile($file); - $this->attributes['transcript_id'] = (new MediaModel())->saveMedia($transcript); + $this->attributes['transcript_id'] = (new MediaModel('transcript'))->saveMedia($transcript); } return $this; @@ -313,7 +313,7 @@ class Episode extends Entity ]); $chapters->setFile($file); - $this->attributes['chapters_id'] = (new MediaModel())->saveMedia($chapters); + $this->attributes['chapters_id'] = (new MediaModel('chapters'))->saveMedia($chapters); } return $this; diff --git a/app/Entities/Media/BaseMedia.php b/app/Entities/Media/BaseMedia.php index 5dbed8799d2c06ee0102938f6dc4b22b4d775bba..6417095cce68e35398a17920e955b344ecba22fe 100644 --- a/app/Entities/Media/BaseMedia.php +++ b/app/Entities/Media/BaseMedia.php @@ -20,7 +20,6 @@ use CodeIgniter\Files\File; * @property string $file_directory * @property string $file_extension * @property string $file_name - * @property string $file_name_with_extension * @property int $file_size * @property string $file_mimetype * @property array|null $file_metadata @@ -80,7 +79,6 @@ class BaseMedia extends Entity $this->attributes['file_name'] = $filename; $this->attributes['file_directory'] = $dirname; $this->attributes['file_extension'] = $extension; - $this->attributes['file_name_with_extension'] = "{$filename}.{$extension}"; } } diff --git a/app/Entities/Media/Transcript.php b/app/Entities/Media/Transcript.php index 2a06ef1d4c94bf2c40c78cfaa461b16f6a2ea63b..bbdb35aadfb3dd8b6716b0bbcbc3bb171bd69b71 100644 --- a/app/Entities/Media/Transcript.php +++ b/app/Entities/Media/Transcript.php @@ -10,7 +10,65 @@ declare(strict_types=1); namespace App\Entities\Media; +use App\Libraries\TranscriptParser; +use CodeIgniter\Files\File; + class Transcript extends BaseMedia { protected string $type = 'transcript'; + + protected ?string $json_path = null; + + protected ?string $json_url = null; + + public function initFileProperties(): void + { + parent::initFileProperties(); + + if ($this->file_path && $this->file_metadata && array_key_exists('json_path', $this->file_metadata)) { + helper('media'); + + $this->json_path = media_path($this->file_metadata['json_path']); + $this->json_url = media_base_url($this->file_metadata['json_path']); + } + } + + public function setFile(File $file): self + { + parent::setFile($file); + + $content = file_get_contents(media_path($this->attributes['file_path'])); + + if ($content === false) { + return $this; + } + + $metadata = []; + if ($fileMetadata = lstat((string) $file)) { + $metadata = $fileMetadata; + } + + $transcriptParser = new TranscriptParser(); + $jsonFilePath = $this->attributes['file_directory'] . '/' . $this->attributes['file_name'] . '.json'; + if (($transcriptJson = $transcriptParser->loadString($content)->parseSrt()) && file_put_contents( + media_path($jsonFilePath), + $transcriptJson + )) { + // set metadata (generated json file path) + $metadata['json_path'] = $jsonFilePath; + } + + $this->attributes['file_metadata'] = json_encode($metadata); + + return $this; + } + + public function deleteFile(): void + { + parent::deleteFile(); + + if ($this->json_path) { + unlink($this->json_path); + } + } } diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php index a270890745b01a1eb0e1e25887c97f28ad0deb64..133302e3d581336c025091bcc4f7d6ce6c6747b6 100644 --- a/app/Helpers/misc_helper.php +++ b/app/Helpers/misc_helper.php @@ -206,3 +206,64 @@ if (! function_exists('podcast_uuid')) { } //-------------------------------------------------------------------- + + +if (! function_exists('file_upload_max_size')) { + + /** + * Returns a file size limit in bytes based on the PHP upload_max_filesize and post_max_size Adapted from: + * https://stackoverflow.com/a/25370978 + */ + function file_upload_max_size(): float + { + static $max_size = -1; + + if ($max_size < 0) { + // Start with post_max_size. + $post_max_size = parse_size((string) ini_get('post_max_size')); + if ($post_max_size > 0) { + $max_size = $post_max_size; + } + + // If upload_max_size is less, then reduce. Except if upload_max_size is + // zero, which indicates no limit. + $upload_max = parse_size((string) ini_get('upload_max_filesize')); + if ($upload_max > 0 && $upload_max < $max_size) { + $max_size = $upload_max; + } + } + return $max_size; + } +} + +if (! function_exists('parse_size')) { + function parse_size(string $size): float + { + $unit = (string) preg_replace('~[^bkmgtpezy]~i', '', $size); // Remove the non-unit characters from the size. + $size = (float) preg_replace('~[^0-9\.]~', '', $size); // Remove the non-numeric characters from the size. + if ($unit !== '') { + // Find the position of the unit in the ordered string which is the power of magnitude to multiply a kilobyte by. + return round($size * pow(1024, (float) stripos('bkmgtpezy', $unit[0]))); + } + + return round($size); + } +} + +if (! function_exists('format_bytes')) { + /** + * Adapted from https://stackoverflow.com/a/2510459 + */ + function formatBytes(float $bytes, int $precision = 2): string + { + $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= pow(1024, $pow); + + return round($bytes, $precision) . $units[$pow]; + } +} diff --git a/app/Libraries/TranscriptParser.php b/app/Libraries/TranscriptParser.php new file mode 100644 index 0000000000000000000000000000000000000000..66c07b6e580c0d02c3d692e2f7857404b9852539 --- /dev/null +++ b/app/Libraries/TranscriptParser.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +/** + * Generates and renders a breadcrumb based on the current url segments + * + * @copyright 2022 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Libraries; + +use stdClass; + +class TranscriptParser +{ + protected string $transcriptContent; + + public function loadString(string $content): self + { + $this->transcriptContent = $content; + + return $this; + } + + /** + * Adapted from: https://stackoverflow.com/a/11659306 + */ + public function parseSrt(): string | false + { + define('SRT_STATE_SUBNUMBER', 0); + define('SRT_STATE_TIME', 1); + define('SRT_STATE_TEXT', 2); + define('SRT_STATE_BLANK', 3); + + $subs = []; + $state = SRT_STATE_SUBNUMBER; + $subNum = 0; + $subText = ''; + $subTime = ''; + + $lines = explode(PHP_EOL, $this->transcriptContent); + foreach ($lines as $line) { + // @phpstan-ignore-next-line + switch ($state) { + case SRT_STATE_SUBNUMBER: + $subNum = trim($line); + $state = SRT_STATE_TIME; + break; + + case SRT_STATE_TIME: + $subTime = trim($line); + $state = SRT_STATE_TEXT; + break; + + case SRT_STATE_TEXT: + if (trim($line) === '') { + $sub = new stdClass(); + $sub->number = (int) $subNum; + [$startTime, $endTime] = explode(' --> ', $subTime); + $sub->startTime = $this->getSecondsFromTimeString($startTime); + $sub->endTime = $this->getSecondsFromTimeString($endTime); + $sub->text = trim($subText); + $subText = ''; + $state = SRT_STATE_SUBNUMBER; + + $subs[] = $sub; + } else { + $subText .= $line; + } + break; + + } + } + + if ($state === SRT_STATE_TEXT) { + // if file was missing the trailing newlines, we'll be in this + // state here. Append the last read text and add the last sub. + // @phpstan-ignore-next-line + $sub->text = $subText; + // @phpstan-ignore-next-line + $subs[] = $sub; + } + + return json_encode($subs, JSON_PRETTY_PRINT); + } + + private function getSecondsFromTimeString(string $timeString): float + { + $timeString = explode(',', $timeString); + return (strtotime($timeString[0]) - strtotime('TODAY')) + (float) "0.{$timeString[1]}"; + } +} diff --git a/app/Resources/icons/file-download.svg b/app/Resources/icons/file-download.svg new file mode 100644 index 0000000000000000000000000000000000000000..0202c99a7f1d9b640d1beb0c9b96b8c1e59ae442 --- /dev/null +++ b/app/Resources/icons/file-download.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M16 2l5 5v14.008a.993.993 0 0 1-.993.992H3.993A1 1 0 0 1 3 21.008V2.992C3 2.444 3.445 2 3.993 2H16zm-3 10V8h-2v4H8l4 4 4-4h-3z"/> + </g> +</svg> diff --git a/app/Resources/icons/file.svg b/app/Resources/icons/file.svg deleted file mode 100755 index d10c86cf8168c2a1ec7e1ed6b16c96ada84cdcb4..0000000000000000000000000000000000000000 --- a/app/Resources/icons/file.svg +++ /dev/null @@ -1,6 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <g> - <path fill="none" d="M0 0h24v24H0z"/> - <path d="M19 22H5a3 3 0 0 1-3-3V3a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v12h4v4a3 3 0 0 1-3 3zm-1-5v2a1 1 0 0 0 2 0v-2h-2zM6 7v2h8V7H6zm0 4v2h8v-2H6zm0 4v2h5v-2H6z"/> - </g> -</svg> diff --git a/app/Resources/js/admin.ts b/app/Resources/js/admin.ts index d9ee93cb82b12369e8c7f060e6dde87eff1cf6cb..078e919f88bf160e0b0dca18bec6d66abb5d4089 100644 --- a/app/Resources/js/admin.ts +++ b/app/Resources/js/admin.ts @@ -18,6 +18,7 @@ import Slugify from "./modules/Slugify"; import ThemePicker from "./modules/ThemePicker"; import Time from "./modules/Time"; import Tooltip from "./modules/Tooltip"; +import ValidateFileSize from "./modules/ValidateFileSize"; import "./modules/video-clip-previewer"; import VideoClipBuilder from "./modules/VideoClipBuilder"; import "./modules/xml-editor"; @@ -35,4 +36,5 @@ Clipboard(); ThemePicker(); PublishMessageWarning(); HotKeys(); +ValidateFileSize(); VideoClipBuilder(); diff --git a/app/Resources/js/modules/ValidateFileSize.ts b/app/Resources/js/modules/ValidateFileSize.ts new file mode 100644 index 0000000000000000000000000000000000000000..b843d5eaa5e74a1dfb75bc9c33cb6e4dab8b1e19 --- /dev/null +++ b/app/Resources/js/modules/ValidateFileSize.ts @@ -0,0 +1,22 @@ +const ValidateFileSize = (): void => { + const fileInputContainers: NodeListOf<HTMLInputElement> = + document.querySelectorAll("[data-max-size]"); + + for (let i = 0; i < fileInputContainers.length; i++) { + const fileInput = fileInputContainers[i] as HTMLInputElement; + + fileInput.addEventListener("change", () => { + if (fileInput.files) { + const fileSize = fileInput.files[0].size; + + if (fileSize > parseFloat(fileInput.dataset.maxSize ?? "0")) { + alert(fileInput.dataset.maxSizeError); + // remove the selected file by resetting input to prevent from uploading it. + fileInput.value = ""; + } + } + }); + } +}; + +export default ValidateFileSize; diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php index 01dbb051a6f2a39f1ef299d5901ec843c09f6c04..2594972ea4fd4ff4ba51338b3da88f4eb854cdcd 100644 --- a/modules/Admin/Controllers/EpisodeController.php +++ b/modules/Admin/Controllers/EpisodeController.php @@ -116,7 +116,7 @@ class EpisodeController extends BaseController 'cover' => 'is_image[cover]|ext_in[cover,jpg,png]|min_dims[cover,1400,1400]|is_image_ratio[cover,1,1]', 'transcript_file' => - 'ext_in[transcript,txt,html,srt,json]|permit_empty', + 'ext_in[transcript,srt]|permit_empty', 'chapters_file' => 'ext_in[chapters,json]|permit_empty', ]; diff --git a/modules/Admin/Language/en/Common.php b/modules/Admin/Language/en/Common.php index d105e76d0d9371f60ca4f9255fef0493b06527ae..58a0a48667fb90c795f6bf1a31d488e6eb482fdf 100644 --- a/modules/Admin/Language/en/Common.php +++ b/modules/Admin/Language/en/Common.php @@ -45,4 +45,5 @@ return [ 'play' => 'Play', 'playing' => 'Playing', ], + 'size_limit' => 'Size limit: {0}.', ]; diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php index cce9992ff7716f0d7053d877faef5ff449746fa9..7188b60c924df6a57258ee42ec302b14369bb444 100644 --- a/modules/Admin/Language/en/Episode.php +++ b/modules/Admin/Language/en/Episode.php @@ -48,8 +48,8 @@ return [ 'editSuccess' => 'Episode has been successfully updated!', ], 'form' => [ - 'warning' => - 'In case of fatal error, try increasing the `memory_limit`, `upload_max_filesize` and `post_max_size` values in your php configuration file then restart your web server.<br />These values must be higher than the audio file you wish to upload.', + 'file_size_error' => + 'Your file size is too big! Max size is {0}. Increase the `memory_limit`, `upload_max_filesize` and `post_max_size` values in your php configuration file then restart your web server to upload your file.', 'audio_file' => 'Audio file', 'audio_file_hint' => 'Choose an .mp3 or .m4a audio file.', 'info_section_title' => 'Episode info', @@ -93,13 +93,15 @@ return [ 'location_section_subtitle' => 'What place is this episode about?', 'location_name' => 'Location name or address', 'location_name_hint' => 'This can be a real or fictional location', - 'transcript' => 'Transcript or closed captions', - 'transcript_hint' => 'Allowed formats are txt, html, srt or json.', - 'transcript_file' => 'Transcript file', + 'transcript' => 'Transcript (subtitles / closed captions)', + 'transcript_hint' => 'Only .srt are allowed.', + 'transcript_download' => 'Download transcript', + 'transcript_file' => 'Transcript file (.srt)', 'transcript_remote_url' => 'Remote url for transcript', 'transcript_file_delete' => 'Delete transcript file', 'chapters' => 'Chapters', 'chapters_hint' => 'File must be in JSON Chapters format.', + 'chapters_download' => 'Download chapters', 'chapters_file' => 'Chapters file', 'chapters_remote_url' => 'Remote url for chapters file', 'chapters_file_delete' => 'Delete chapters file', diff --git a/modules/Admin/Language/fr/Common.php b/modules/Admin/Language/fr/Common.php index f856cf907362fa5b5565cf524b7d47c47e245457..531a8e5920c87c7996c947f3fc0bb9d007df1fdc 100644 --- a/modules/Admin/Language/fr/Common.php +++ b/modules/Admin/Language/fr/Common.php @@ -45,4 +45,5 @@ return [ 'play' => 'Lire', 'playing' => 'En cours', ], + 'size_limit' => 'Taille maximale : {0}.', ]; diff --git a/modules/Admin/Language/fr/Episode.php b/modules/Admin/Language/fr/Episode.php index 1ff5a800af82dad7b23c58efebc31a2b37e571fd..610bb35f3a360d35412913c2032bf1c7bc16aa54 100644 --- a/modules/Admin/Language/fr/Episode.php +++ b/modules/Admin/Language/fr/Episode.php @@ -49,8 +49,8 @@ return [ 'editSuccess' => 'L’épisode a bien été mis à jour !', ], 'form' => [ - 'warning' => - 'En cas d’erreur fatale, essayez d’augmenter les valeurs de `memory_limit`, `upload_max_filesize` et `post_max_size` dans votre fichier de configuration php puis redémarrez votre serveur web.<br />Les valeurs doivent être plus grandes que le fichier audio que vous souhaitez téléverser.', + 'file_size_error' => + 'Votre fichier est trop lourd ! La taille maximale est de {0}. Augmentez les valeurs de `memory_limit`, `upload_max_filesize` et `post_max_size` dans votre fichier de configuration php puis redémarrez votre serveur web pour téléverser votre fichier.', 'audio_file' => 'Fichier audio', 'audio_file_hint' => 'Sélectionnez un fichier audio .mp3 ou .m4a.', 'info_section_title' => 'Informations épisode', @@ -94,15 +94,16 @@ return [ 'location_section_subtitle' => 'De quel lieu cet épisode parle-t-il ?', 'location_name' => 'Nom ou adresse du lieu', 'location_name_hint' => 'Ce lieu peut être réel ou fictif', - 'transcript' => 'Transcription ou sous-titrage', - 'transcript_hint' => - 'Les formats autorisés sont txt, html, srt ou json.', - 'transcript_file' => 'Fichier de transcription', + 'transcript' => 'Transcription (sous-titrage)', + 'transcript_hint' => 'Seulement les .srt sont autorisés', + 'transcript_download' => 'Télécharger le transcript', + 'transcript_file' => 'Fichier de transcription (.srt)', 'transcript_remote_url' => 'URL distante pour le fichier de transcription', 'transcript_file_delete' => 'Supprimer le fichier de transcription', 'chapters' => 'Chapitrage', 'chapters_hint' => 'Le fichier doit être en format “JSON Chaptersâ€.', + 'chapters_download' => 'Télécharger le chapitrage', 'chapters_file' => 'Fichier de chapitrage', 'chapters_remote_url' => 'URL distante pour le fichier de chapitrage', diff --git a/themes/cp_admin/episode/create.php b/themes/cp_admin/episode/create.php index 39a440cf5cfe6d1c0c6b3cb3323fedebe3a44d6c..90eb4b7f84f271918e9e43c6d35a703c9f3875ed 100644 --- a/themes/cp_admin/episode/create.php +++ b/themes/cp_admin/episode/create.php @@ -11,8 +11,6 @@ <?= $this->section('content') ?> -<Alert variant="danger" glyph="alert" class="max-w-xl"><?= lang('Episode.form.warning') ?></Alert> - <form action="<?= route_to('episode-create', $podcast->id) ?>" method="POST" enctype="multipart/form-data" class="flex flex-col max-w-xl mt-6 gap-y-8"> <?= csrf_field() ?> @@ -23,9 +21,12 @@ name="audio_file" label="<?= lang('Episode.form.audio_file') ?>" hint="<?= lang('Episode.form.audio_file_hint') ?>" + helper="<?= lang('Common.size_limit', [formatBytes(file_upload_max_size())]) ?>" type="file" accept=".mp3,.m4a" - required="true" /> + required="true" + data-max-size="<?= file_upload_max_size() ?>" + data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size())]) ?>" /> <Forms.Field name="cover" diff --git a/themes/cp_admin/episode/edit.php b/themes/cp_admin/episode/edit.php index b693cb9a09fabcbb9c482d2a1f53a89b358fb5ad..86a5bc9856db840f1136f44d8ea0306f2b8cc594 100644 --- a/themes/cp_admin/episode/edit.php +++ b/themes/cp_admin/episode/edit.php @@ -15,8 +15,6 @@ <?= $this->section('content') ?> -<Alert variant="danger" glyph="alert" class="max-w-xl"><?= lang('Episode.form.warning') ?></Alert> - <form id="episode-edit-form" action="<?= route_to('episode-edit', $podcast->id, $episode->id) ?>" method="POST" enctype="multipart/form-data" class="flex flex-col max-w-xl mt-6 gap-y-8"> <?= csrf_field() ?> @@ -27,14 +25,17 @@ name="audio_file" label="<?= lang('Episode.form.audio_file') ?>" hint="<?= lang('Episode.form.audio_file_hint') ?>" + helper="<?= lang('Common.size_limit', [formatBytes(file_upload_max_size())]) ?>" type="file" - accept=".mp3,.m4a" /> + accept=".mp3,.m4a" + data-max-size="<?= file_upload_max_size() ?>" + data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size())]) ?>" /> <Forms.Field name="cover" label="<?= lang('Episode.form.cover') ?>" hint="<?= lang('Episode.form.cover_hint') ?>" - helper="<?= lang('Episode.form.cover_size_hint', ) ?>" + helper="<?= lang('Episode.form.cover_size_hint') ?>" type="file" accept=".jpg,.jpeg,.png" /> @@ -166,12 +167,10 @@ <div class="flex items-center mb-1 gap-x-2"> <?= anchor( $episode->transcript->file_url, - icon('file', 'mr-2 text-skin-muted') . - $episode->transcript->file_name_with_extension, + icon('file-download', 'mr-1 text-skin-muted text-xl') . lang('Episode.form.transcript_download'), [ - 'class' => 'inline-flex items-center text-xs', - 'target' => '_blank', - 'rel' => 'noreferrer noopener', + 'class' => 'flex-1 font-semibold hover:underline inline-flex items-center text-xs', + 'download' => '', ], ) . anchor( @@ -223,11 +222,10 @@ <div class="flex mb-1 gap-x-2"> <?= anchor( $episode->chapters->file_url, - icon('file', 'mr-2') . $episode->chapters->file_name_with_extension, + icon('file-download', 'mr-1 text-skin-muted text-xl') . lang('Episode.form.chapters_download'), [ - 'class' => 'inline-flex items-center text-xs', - 'target' => '_blank', - 'rel' => 'noreferrer noopener', + 'class' => 'flex-1 font-semibold hover:underline inline-flex items-center text-xs', + 'download' => '', ], ) . anchor(