Commit 00987610 authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat(transcript): parse srt subtitles into json file + add max file size info...

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
parent 16705584
Pipeline #1192 passed with stages
in 7 minutes and 39 seconds
......@@ -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.
......
......@@ -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;
......
......@@ -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}";
}
}
......
......@@ -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);
}
}
}
......@@ -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];
}
}
<?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]}";
}
}
<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"/>
<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>
......@@ -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();
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;
......@@ -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',
];
......
......@@ -45,4 +45,5 @@ return [
'play' => 'Play',
'playing' => 'Playing',
],
'size_limit' => 'Size limit: {0}.',
];
......@@ -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',
......
......@@ -45,4 +45,5 @@ return [
'play' => 'Lire',
'playing' => 'En cours',
],
'size_limit' => 'Taille maximale : {0}.',
];
......@@ -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',
......
......@@ -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"
......
......@@ -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(
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment