From 00987610a068c8d6cdd4421ea16585fa037eb61a Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Sun, 9 Jan 2022 16:37:13 +0000 Subject: [PATCH] feat(transcript): parse srt subtitles into json file + add max file size info below audio file input remove episode form warning + add javascript validation when uploading a file to check if it's too big to upload --- UPDATE.md | 2 +- app/Entities/Episode.php | 4 +- app/Entities/Media/BaseMedia.php | 2 - app/Entities/Media/Transcript.php | 58 +++++++++++ app/Helpers/misc_helper.php | 61 ++++++++++++ app/Libraries/TranscriptParser.php | 95 +++++++++++++++++++ app/Resources/icons/file-download.svg | 6 ++ app/Resources/icons/file.svg | 6 -- app/Resources/js/admin.ts | 2 + app/Resources/js/modules/ValidateFileSize.ts | 22 +++++ .../Admin/Controllers/EpisodeController.php | 2 +- modules/Admin/Language/en/Common.php | 1 + modules/Admin/Language/en/Episode.php | 12 ++- modules/Admin/Language/fr/Common.php | 1 + modules/Admin/Language/fr/Episode.php | 13 +-- themes/cp_admin/episode/create.php | 7 +- themes/cp_admin/episode/edit.php | 24 +++-- 17 files changed, 279 insertions(+), 39 deletions(-) create mode 100644 app/Libraries/TranscriptParser.php create mode 100644 app/Resources/icons/file-download.svg delete mode 100755 app/Resources/icons/file.svg create mode 100644 app/Resources/js/modules/ValidateFileSize.ts diff --git a/UPDATE.md b/UPDATE.md index fe8923ae..1185b00b 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 57c4e672..80286775 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 5dbed879..6417095c 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 2a06ef1d..bbdb35aa 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 a2708907..133302e3 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 00000000..66c07b6e --- /dev/null +++ b/app/Libraries/TranscriptParser.php @@ -0,0 +1,95 @@ +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 00000000..0202c99a --- /dev/null +++ b/app/Resources/icons/file-download.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Resources/icons/file.svg b/app/Resources/icons/file.svg deleted file mode 100755 index d10c86cf..00000000 --- a/app/Resources/icons/file.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/Resources/js/admin.ts b/app/Resources/js/admin.ts index d9ee93cb..078e919f 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 00000000..b843d5ea --- /dev/null +++ b/app/Resources/js/modules/ValidateFileSize.ts @@ -0,0 +1,22 @@ +const ValidateFileSize = (): void => { + const fileInputContainers: NodeListOf = + 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 01dbb051..2594972e 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 d105e76d..58a0a486 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 cce9992f..7188b60c 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.
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 f856cf90..531a8e59 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 1ff5a800..610bb35f 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.
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 39a440cf..90eb4b7f 100644 --- a/themes/cp_admin/episode/create.php +++ b/themes/cp_admin/episode/create.php @@ -11,8 +11,6 @@ section('content') ?> - -
@@ -23,9 +21,12 @@ name="audio_file" label="" hint="" + helper="" type="file" accept=".mp3,.m4a" - required="true" /> + required="true" + data-max-size="" + data-max-size-error="" /> section('content') ?> - - @@ -27,14 +25,17 @@ name="audio_file" label="" hint="" + helper="" type="file" - accept=".mp3,.m4a" /> + accept=".mp3,.m4a" + data-max-size="" + data-max-size-error="" /> @@ -166,12 +167,10 @@
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 @@
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(