mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-19 13:01:19 +00:00
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
This commit is contained in:
parent
1670558473
commit
00987610a0
@ -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];
|
||||
}
|
||||
}
|
||||
|
95
app/Libraries/TranscriptParser.php
Normal file
95
app/Libraries/TranscriptParser.php
Normal file
@ -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]}";
|
||||
}
|
||||
}
|
6
app/Resources/icons/file-download.svg
Normal file
6
app/Resources/icons/file-download.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 278 B |
@ -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>
|
Before Width: | Height: | Size: 298 B |
@ -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();
|
||||
|
22
app/Resources/js/modules/ValidateFileSize.ts
Normal file
22
app/Resources/js/modules/ValidateFileSize.ts
Normal file
@ -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;
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user