Toolvana: production-ready media tools platform (CodeIgniter 4)

- 134-tool registry with programmatic SEO (unique titles/H1/descriptions,
  JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter)
- DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD),
  Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG)
- Security: SSRF guard, MIME validation, rate limits, API-key auth,
  bcrypt admin login, security headers
- Admin panel: dashboard, tools/categories/guides CRUD, SEO audit,
  analytics, job inspector with retry, system health, feature flags
- Docker deployment (nginx + web/api FPM pools + scalable workers),
  PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
This commit is contained in:
deepseek
2026-08-23 07:10:30 +00:00
commit beaf0e1f37
217 changed files with 19619 additions and 0 deletions
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Libraries\Pipeline;
/**
* A processing driver turns a job row into an output file.
* handle() receives the raw job, the registry tool and an update()
* callback for progress reporting (0-99 + stage label), and must
* return ['file' => basename, 'name' => download name, 'ext' => 'mp3'].
*/
interface DriverInterface
{
public function handle(array $job, array $tool, callable $update): array;
}
+573
View File
@@ -0,0 +1,573 @@
<?php
declare(strict_types=1);
namespace App\Libraries\Pipeline;
use App\Libraries\Process;
/**
* FFmpeg / ffprobe driver for video and audio operations.
*
* Security model: every operation maps to a fixed argument template.
* User input only fills validated placeholders (numbers, enums, paths
* inside the storage sandbox) — never raw strings appended to a command.
*/
final class Ffmpeg implements DriverInterface
{
private const AUDIO_EXT = ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg', 'opus', 'wma'];
/** Audio codec mapping: ext => [codec, extra args] */
private const AUDIO_CODECS = [
'mp3' => ['libmp3lame', ['-b:a']],
'wav' => ['pcm_s16le', []],
'm4a' => ['aac', ['-b:a']],
'aac' => ['aac', ['-b:a']],
'flac' => ['flac', []],
'ogg' => ['libvorbis', ['-b:a']],
'opus' => ['libopus', ['-b:a']],
];
public function handle(array $job, array $tool, callable $update): array
{
$params = json_decode($job['params'] ?? '{}', true) ?: [];
$input = self::resolveInput($job);
$update(5, 'fetching');
return match ($job['operation']) {
'convert' => isset($params['_mode']) && $params['_mode'] === 'audio' || in_array($this->targetExt($job, $tool), self::AUDIO_EXT, true)
? $this->convertAudio($input, $job, $tool, $update, $params)
: $this->convertVideo($input, $job, $tool, $update, $params),
'compress' => $this->compress($input, $job, $tool, $update, $params),
'resize' => $this->scale($input, $job, $tool, $update, $params),
'crop' => $this->crop($input, $job, $tool, $update, $params),
'trim' => $this->trim($input, $job, $tool, $update, $params),
'cut' => $this->trim($input, $job, $tool, $update, $params),
'merge' => $this->merge($input, $job, $tool, $update),
'rotate' => $this->transpose($input, $job, $tool, $update, $params, false),
'flip' => $this->transpose($input, $job, $tool, $update, $params, true),
'speed' => $this->speed($input, $job, $tool, $update, $params),
'fps' => $this->fps($input, $job, $tool, $update, $params),
'extract_audio' => $this->extractAudio($input, $job, $tool, $update, $params),
'remove_audio' => $this->mute($input, $job, $tool, $update, keepVideo: true, silent: true),
'mute' => $this->mute($input, $job, $tool, $update, keepVideo: true, silent: true),
'add_audio' => $this->addAudio($input, $job, $tool, $update, $params),
'add_subtitles' => $this->addSubtitles($input, $job, $tool, $update, $params),
'remove_metadata' => $this->stripMetadata($input, $job, $tool, $update),
'extract_frames' => $this->framesZip($input, $job, $update, $params),
'video_to_gif' => $this->toGif($input, $job, $tool, $update, $params),
'gif_to_video' => $this->convertVideo($input, $job, $tool, $update, ['_force_ext' => 'mp4']),
'normalize' => $this->normalizeAudio($input, $job, $tool, $update),
'volume' => $this->volume($input, $job, $tool, $update, $params),
default => throw new \RuntimeException('Unsupported operation.'),
};
}
// -----------------------------------------------------------------
// helpers shared by operations
// -----------------------------------------------------------------
public static function resolveInput(array $job): string
{
foreach ([\App\Libraries\Pipeline::incomingDir(), \App\Libraries\Pipeline::storageDir()] as $dir) {
$file = self::storagePath((string) ($job['input_file'] ?? ''), $dir);
if (is_file($file)) {
return $file;
}
}
throw new \RuntimeException('Input file missing.');
}
public static function storagePath(string $basename, ?string $baseDir = null): string
{
if (preg_match('/^[A-Za-z0-9._-]+$/', $basename) !== 1 || str_contains($basename, '..')) {
throw new \RuntimeException('Invalid file reference.');
}
$baseDir ??= \App\Libraries\Pipeline::storageDir();
$path = rtrim($baseDir, '/') . '/' . $basename;
if (! str_starts_with(realpath(dirname($path)) ?: '', realpath($baseDir) ?: '')) {
throw new \RuntimeException('Invalid file path.');
}
return $path;
}
private function probe(string $file): array
{
$proc = Process::binary('ffprobe', [
'-v', 'quiet', '-print_format', 'json',
'-show_format', '-show_streams', $file,
], 30);
if (! $proc->run()) {
throw new \RuntimeException('Could not read the media file.');
}
$data = json_decode($proc->out(), true);
return is_array($data) ? $data : [];
}
private function targetExt(array $job, array $tool): string
{
$params = json_decode($job['params'] ?? '{}', true) ?: [];
$ext = strtolower((string) ($params['format'] ?? $tool['primary_output_format'] ?? ''));
if (preg_match('/^[a-z0-9]{1,6}$/', $ext) !== 1) {
throw new \RuntimeException('Unknown output format.');
}
return $ext;
}
private function output(array $job, string $ext): array
{
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
}
private function num(mixed $value, float $min, float $max, float $default): float
{
if (! is_numeric($value)) {
return $default;
}
$v = (float) $value;
return max($min, min($max, $v));
}
// -----------------------------------------------------------------
// operations
// -----------------------------------------------------------------
private function convertAudio(string $input, array $job, array $tool, callable $update, array $params): array
{
$ext = $this->targetExt($job, $tool);
if (! isset(self::AUDIO_CODECS[$ext])) {
throw new \RuntimeException("Format {$ext} is not supported here.");
}
[$codec, $extra] = self::AUDIO_CODECS[$ext];
$bitrate = (int) $this->num($params['bitrate'] ?? 192, 32, 320, 192);
$args = ['-y', '-i', $input, '-vn', '-c:a', $codec];
foreach ($extra as $flag) {
$args[] = $flag;
$args[] = ($bitrate) . 'k';
}
if ($ext === 'mp3' || $ext === 'flac') {
$args[] = '-metadata';
$args[] = 'comment=Processed with Toolvana';
}
[, $out] = $this->output($job, $ext);
$args[] = $out;
$update(15, 'converting');
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Conversion failed. The file may be corrupted or in an unsupported format.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
private function convertVideo(string $input, array $job, array $tool, callable $update, array $params): array
{
$ext = strtolower((string) ($params['_force_ext'] ?? $this->targetExt($job, $tool)));
// [video args, uses CRF?, audio args, mux args]
$map = [
'mp4' => [['-c:v', 'libx264', '-preset', 'medium'], true, ['-c:a', 'aac', '-b:a', '160k'], ['-movflags', '+faststart']],
'webm' => [['-c:v', 'libvpx-vp9'], true, ['-c:a', 'libopus', '-b:a', '128k'], []],
'mkv' => [['-c:v', 'libx264', '-preset', 'medium'], true, ['-c:a', 'copy'], []],
'mov' => [['-c:v', 'libx264', '-preset', 'medium'], true, ['-c:a', 'aac', '-b:a', '160k'], []],
'avi' => [['-c:v', 'mpeg4'], false, ['-c:a', 'libmp3lame', '-b:a', '192k'], []],
];
if (! isset($map[$ext])) {
throw new \RuntimeException("Format {$ext} is not supported here.");
}
[$videoArgs, $usesCrf, $audioArgs, $muxArgs] = $map[$ext];
$args = ['-y', '-i', $input, ...$videoArgs];
if ($usesCrf) {
$args[] = '-crf';
$args[] = (string) (int) $this->num($params['quality'] ?? 23, 18, 34, 23);
} else {
$args[] = '-qscale:v';
$args[] = '3';
}
$args = [...$args, ...$audioArgs, ...$muxArgs];
[, $out] = $this->output($job, $ext);
$args[] = $out;
$update(10, 'converting');
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Conversion failed. The source video could not be processed.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
private function compress(string $input, array $job, array $tool, callable $update, array $params): array
{
$level = (int) $this->num($params['level'] ?? 28, 20, 38, 28); // CRF-style
$info = $this->probe($input);
$hasV = false;
foreach ($info['streams'] ?? [] as $s) {
if (($s['codec_type'] ?? '') === 'video') {
$hasV = true;
}
}
$ext = $hasV ? 'mp4' : 'm4a';
[, $out] = $this->output($job, $ext);
$args = ['-y', '-i', $input];
if ($hasV) {
$args = [...$args, '-c:v', 'libx264', '-preset', 'slow', '-crf', (string) $level, '-pix_fmt', 'yuv420p', '-movflags', '+faststart'];
} else {
$args = [...$args, '-c:a', 'aac', '-b:a', '96k'];
}
$args[] = $out;
$update(10, 'compressing');
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Compression failed.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
private function scale(string $input, array $job, array $tool, callable $update, array $params): array
{
$w = (int) $this->num($params['width'] ?? 0, 16, 7680, 0);
$h = (int) $this->num($params['height'] ?? 0, 16, 4320, 0);
if ($w === 0 && $h === 0) {
throw new \RuntimeException('Choose a target width or height.');
}
$filter = $h > 0 ? "scale=-2:{$h}" : "scale={$w}:-2";
return $this->simpleVideoTransform($input, $job, $update, $filter, 'resizing');
}
private function crop(string $input, array $job, array $tool, callable $update, array $params): array
{
$w = (int) $this->num($params['width'] ?? 1080, 16, 7680, 1080);
$h = (int) $this->num($params['height'] ?? 1080, 16, 4320, 1080);
$x = (int) $this->num($params['x'] ?? 0, 0, 7680, 0);
$y = (int) $this->num($params['y'] ?? 0, 0, 4320, 0);
return $this->simpleVideoTransform($input, $job, $update, "crop={$w}:{$h}:{$x}:{$y}", 'cropping');
}
private function trim(string $input, array $job, array $tool, callable $update, array $params): array
{
$start = max(0.0, $this->num($params['start'] ?? 0, 0, 86400, 0));
$end = $this->num($params['end'] ?? 0, 0, 86401, 0);
if ($end <= $start) {
throw new \RuntimeException('End time must be after start time.');
}
$dur = $end - $start;
$ext = $this->guessContainer($input);
[, $out] = $this->output($job, $ext);
$proc = Process::binary('ffmpeg', [
'-y', '-i', $input, '-ss', (string) $start, '-t', number_format($dur, 3, '.', ''),
'-c', 'copy', '-movflags', '+faststart', $out,
], config('Site')->maxProcessingSeconds);
$update(20, 'cutting');
if (! $proc->run() || ! is_file($out) || filesize($out) < 64) {
// stream copy can fail on some sources — re-encode fallback
$proc2 = Process::binary('ffmpeg', [
'-y', '-i', $input, '-ss', (string) $start, '-t', number_format($dur, 3, '.', ''), $out,
], config('Site')->maxProcessingSeconds);
if (! $proc2->run()) {
throw new \RuntimeException('Trim failed.');
}
}
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
private function merge(string $input, array $job, array $tool, callable $update): array
{
$extraFiles = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
$files = [$input];
foreach ((array) $extraFiles as $f) {
$files[] = self::storagePath((string) $f);
}
if (count($files) < 2) {
throw new \RuntimeException('At least two files are required to merge.');
}
// concat demuxer needs a list file
$listPath = \App\Libraries\Pipeline::incomingDir() . '/' . $job['id'] . '.txt';
$lines = '';
foreach ($files as $f) {
$lines .= "file '" . addcslashes($f, "'\\") . "'\n";
}
file_put_contents($listPath, $lines);
[, $out] = $this->output($job, 'mp4');
$update(15, 'merging');
$proc = Process::binary('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', $listPath, '-c', 'copy', '-movflags', '+faststart', $out], config('Site')->maxProcessingSeconds);
@unlink($listPath);
if (! $proc->run() || ! is_file($out) || filesize($out) < 64) {
throw new \RuntimeException('Merge failed — files must share the same resolution and codecs.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
}
private function transpose(string $input, array $job, array $tool, callable $update, array $params, bool $flip): array
{
if ($flip) {
$dir = ($params['direction'] ?? 'horizontal') === 'vertical' ? 'vflip' : 'hflip';
} else {
$deg = (int) $this->num($params['degrees'] ?? 90, 90, 270, 90);
$map = [90 => 'transpose=clock', 180 => 'transpose=clock,transpose=clock', 270 => 'transpose=cclock'];
$dir = $map[$deg] ?? 'transpose=clock';
}
return $this->simpleVideoTransform($input, $job, $update, $dir, $flip ? 'flipping' : 'rotating');
}
private function speed(string $input, array $job, array $tool, callable $update, array $params): array
{
$factor = $this->num($params['factor'] ?? 1.5, 0.25, 4, 1.5);
if (abs($factor - 1.0) < 0.05) {
throw new \RuntimeException('Speed factor must differ from 1.0.');
}
$atempo = $factor >= 0.5 && $factor <= 2.0
? 'atempo=' . number_format($factor, 3, '.', '')
: 'atempo=2.0,atempo=' . number_format($factor / 2, 3, '.', '');
return $this->simpleVideoTransform(
$input, $job, $update,
'setpts=' . number_format(1 / $factor, 4, '.', '') . '*PTS,' . $atempo,
'changing speed'
);
}
private function fps(string $input, array $job, array $tool, callable $update, array $params): array
{
$fps = $this->num($params['fps'] ?? 24, 1, 120, 24);
return $this->simpleVideoTransform($input, $job, $update, "fps={$fps}", 'converting fps');
}
private function extractAudio(string $input, array $job, array $tool, callable $update, array $params): array
{
$params['format'] = $params['format'] ?? 'mp3';
return $this->convertAudio($input, $job, $tool, $update, $params);
}
private function mute(string $input, array $job, array $tool, callable $update, bool $keepVideo, bool $silent): array
{
$ext = $this->guessContainer($input);
[, $out] = $this->output($job, $ext);
$update(15, 'removing audio');
$proc = Process::binary('ffmpeg', ['-y', '-i', $input, '-an', '-c:v', 'copy', '-movflags', '+faststart', $out], config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Could not remove the audio track.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
private function addAudio(string $input, array $job, array $tool, callable $update, array $params): array
{
$audioFile = self::storagePath((string) ($params['audio_file'] ?? ''));
[, $out] = $this->output($job, 'mp4');
$update(15, 'adding audio');
$shortest = ! empty($params['stop_at_shortest']) ? ['-shortest'] : [];
$proc = Process::binary('ffmpeg', [
'-y', '-i', $input, '-i', $audioFile, '-map', '0:v:0', '-map', '1:a:0',
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', ...$shortest, '-movflags', '+faststart', $out,
], config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Could not combine video and audio.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
}
private function addSubtitles(string $input, array $job, array $tool, callable $update, array $params): array
{
$subFile = self::storagePath((string) ($params['subtitle_file'] ?? ''));
[, $out] = $this->output($job, 'mp4');
$update(15, 'adding subtitles');
$proc = Process::binary('ffmpeg', [
'-y', '-i', $input, '-i', $subFile,
'-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
'-c:a', 'copy', '-c:s', 'mov_text', '-movflags', '+faststart', $out,
], config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Could not embed subtitles. Use an SRT or VTT file.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
}
private function stripMetadata(string $input, array $job, array $tool, callable $update): array
{
$ext = $this->guessContainer($input);
[, $out] = $this->output($job, $ext);
$update(20, 'removing metadata');
$proc = Process::binary('ffmpeg', [
'-y', '-i', $input, '-map_metadata', '-1', '-fflags', '+bitexact',
'-flags:v', '+bitexact', '-flags:a', '+bitexact', '-c', 'copy', $out,
], config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Could not strip metadata.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
private function framesZip(string $input, array $job, callable $update, array $params): array
{
$fps = $this->num($params['fps'] ?? 1, 0.1, 30, 1);
$tmpDir = sys_get_temp_dir() . '/tv_frames_' . $job['id'];
mkdir($tmpDir, 0700, true);
$update(15, 'extracting frames');
$proc = Process::binary('ffmpeg', [
'-y', '-i', $input, '-vf', "fps={$fps}", '-q:v', '2',
rtrim($tmpDir, '/') . '/frame_%05d.jpg',
], config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Frame extraction failed.');
}
$zipPath = \App\Libraries\Pipeline::storageDir() . '/' . \App\Libraries\Pipeline::safeName($job['id'], 'zip');
$zip = new \ZipArchive();
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
$count = 0;
foreach (glob(rtrim($tmpDir, '/') . '/frame_*.jpg') ?: [] as $frame) {
$zip->addFile($frame, basename($frame));
++$count;
}
$zip->close();
foreach (glob(rtrim($tmpDir, '/') . '/*') ?: [] as $f) {
@unlink($f);
}
@rmdir($tmpDir);
if ($count === 0) {
throw new \RuntimeException('No frames were extracted.');
}
return ['file' => basename($zipPath), 'name' => $this->downloadName($job, 'zip'), 'ext' => 'zip'];
}
private function toGif(string $input, array $job, array $tool, callable $update, array $params): array
{
$width = (int) $this->num($params['width'] ?? 480, 64, 1280, 480);
$fps = $this->num($params['fps'] ?? 12, 4, 24, 12);
[, $out] = $this->output($job, 'gif');
$palette = sys_get_temp_dir() . '/tv_palette_' . $job['id'] . '.png';
$update(10, 'analyzing colors');
$p1 = Process::binary('ffmpeg', [
'-y', '-t', '60', '-i', $input, '-vf',
"fps={$fps},scale={$width}:-1:flags=lanczos,palettegen", $palette,
], 300);
$p1->run();
$update(25, 'generating GIF');
$vf = "fps={$fps},scale={$width}:-1:flags=lanczos";
if (is_file($palette)) {
$vf .= '[x];[x][1:v]paletteuse';
$args = ['-y', '-i', $input, '-i', $palette, '-filter_complex', $vf, $out];
} else {
$args = ['-y', '-i', $input, '-vf', $vf, $out];
}
$p2 = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
@unlink($palette);
if (! $p2->run() || ! is_file($out) || filesize($out) === 0) {
throw new \RuntimeException('GIF generation failed.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, 'gif'), 'ext' => 'gif'];
}
private function normalizeAudio(string $input, array $job, array $tool, callable $update): array
{
$params = json_decode($job['params'] ?? '{}', true) ?: [];
$ext = $params['format'] ?? 'mp3';
[$codec, $extra] = self::AUDIO_CODECS[strtolower((string) $ext)] ?? self::AUDIO_CODECS['mp3'];
[, $out] = $this->output($job, $ext);
$update(10, 'normalizing');
$args = ['-y', '-i', $input, '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-ar', '48000', '-c:a', $codec];
foreach ($extra as $flag) {
$args[] = $flag;
$args[] = '192k';
}
$args[] = $out;
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Normalization failed.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
private function volume(string $input, array $job, array $tool, callable $update, array $params): array
{
$gain = $this->num($params['gain_db'] ?? 6, -30, 30, 6);
$ext = strtolower((string) (json_decode($job['params'] ?? '{}', true)['format'] ?? 'mp3'));
[$codec, $extra] = self::AUDIO_CODECS[$ext] ?? self::AUDIO_CODECS['mp3'];
[, $out] = $this->output($job, $ext);
$update(15, 'boosting volume');
$args = ['-y', '-i', $input, '-af', 'volume=' . number_format($gain, 1, '.', '') . 'dB', '-c:a', $codec];
foreach ($extra as $flag) {
$args[] = $flag;
$args[] = '192k';
}
$args[] = $out;
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Volume adjustment failed.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
// -----------------------------------------------------------------
private function simpleVideoTransform(string $input, array $job, callable $update, string $filter, string $stage): array
{
[, $out] = $this->output($job, 'mp4');
$update(15, $stage);
$proc = Process::binary('ffmpeg', [
'-y', '-i', $input, '-vf', $filter,
'-c:v', 'libx264', '-preset', 'fast', '-crf', '21',
'-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', $out,
], config('Site')->maxProcessingSeconds);
if (! $proc->run() || ! is_file($out) || filesize($out) === 0) {
throw new \RuntimeException(ucfirst($stage) . ' failed.');
}
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
}
private function guessContainer(string $input): string
{
$ext = strtolower(pathinfo($input, PATHINFO_EXTENSION));
return in_array($ext, ['mp4', 'webm', 'mkv', 'mov', 'avi'], true) ? $ext : 'mp4';
}
private function downloadName(array $job, string $ext): string
{
$base = pathinfo((string) ($job['input_name'] ?? 'media'), PATHINFO_FILENAME);
$base = preg_replace('/[^A-Za-z0-9 _.-]/u', '_', $base) ?: 'media';
return mb_substr($base, 0, 80) . '.' . $ext;
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Libraries\Pipeline;
/**
* Image driver — GD-based (jpg/png/webp/avif where the build supports
* it), with ImageMagick as a fallback for exotic formats. Re-encoding
* doubles as metadata stripping.
*/
final class Images implements DriverInterface
{
public function handle(array $job, array $tool, callable $update): array
{
$input = Ffmpeg::resolveInput($job);
$params = json_decode($job['params'] ?? '{}', true) ?: [];
$update(10, 'processing');
$image = imagecreatefromstring((string) file_get_contents($input));
if ($image === false) {
throw new \RuntimeException('The file could not be read as an image.');
}
// preserve orientation of JPEGs
$exif = @exif_read_data($input);
if ($exif !== false && isset($exif['Orientation'])) {
$angle = match ((int) $exif['Orientation']) {
3 => 180, 6 => -90, 8 => 90, default => null,
};
if ($angle !== null) {
$image = imagerotate($image, $angle, 0);
}
}
if (function_exists('imagepalettetotruecolor')) {
imagepalettetotruecolor($image);
}
imagealphablending($image, true);
imagesavealpha($image, true);
// resize / crop before encoding
$w = imagesx($image);
$h = imagesy($image);
if (! empty($params['width']) || ! empty($params['height'])) {
[$nw, $nh] = $this->fitSize($w, $h, (int) ($params['width'] ?? 0), (int) ($params['height'] ?? 0));
$scaled = imagecreatetruecolor(max(1, $nw), max(1, $nh));
imagealphablending($scaled, false);
imagesavealpha($scaled, true);
imagecopyresampled($scaled, $image, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($image);
$image = $scaled;
$update(55, 'resizing');
}
if (! empty($params['crop_width']) && ! empty($params['crop_height'])) {
$cw = min(imagesx($image), (int) $params['crop_width']);
$ch = min(imagesy($image), (int) $params['crop_height']);
$cx = max(0, min(imagesx($image) - $cw, (int) ($params['crop_x'] ?? 0)));
$cy = max(0, min(imagesy($image) - $ch, (int) ($params['crop_y'] ?? 0)));
$cropped = imagecreatetruecolor($cw, $ch);
imagealphablending($cropped, false);
imagesavealpha($cropped, true);
imagecopy($cropped, $image, 0, 0, $cx, $cy, $cw, $ch);
imagedestroy($image);
$image = $cropped;
$update(70, 'cropping');
}
if (($params['rotate'] ?? 0) != 0) {
$deg = in_array(abs((int) $params['rotate']), [90, 180, 270], true) ? (int) $params['rotate'] : 90;
$image = imagerotate($image, -$deg, 0); // GD rotates counter-clockwise
$update(70, 'rotating');
}
if (! empty($params['flip'])) {
imageflip($image, $params['flip'] === 'vertical' ? IMG_FLIP_VERTICAL : IMG_FLIP_HORIZONTAL);
}
$ext = strtolower((string) ($params['format'] ?? $tool['primary_output_format'] ?? pathinfo($job['input_file'], PATHINFO_EXTENSION)));
if ($ext === 'jpeg') {
$ext = 'jpg';
}
$quality = (int) max(30, min(100, (int) ($params['quality'] ?? 85)));
[, $out] = $this->outputPath($job, $ext);
$ok = match ($ext) {
'jpg' => imagejpeg($image, $out, $quality),
'png' => imagepng($image, $out, (int) round(9 * (1 - $quality / 100))),
'webp' => function_exists('imagewebp') ? imagewebp($image, $out, $quality) : false,
'avif' => function_exists('imageavif') ? imageavif($image, $out, $quality) : false,
'gif' => imagegif($image, $out),
default => throw new \RuntimeException("Format {$ext} is not supported."),
};
imagedestroy($image);
if (! $ok) {
throw new \RuntimeException("Saving as {$ext} is not supported by this server.");
}
$update(95, 'finalizing');
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
private function fitSize(int $w, int $h, int $targetW, int $targetH): array
{
if ($targetW > 0 && $targetH > 0) {
return [min($targetW, $w * 4), min($targetH, $h * 4)];
}
if ($targetW > 0) {
return [$targetW, max(1, (int) round($h * $targetW / $w))];
}
return [max(1, (int) round($w * $targetH / $h)), $targetH];
}
private function outputPath(array $job, string $ext): array
{
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
}
private function downloadName(array $job, string $ext): string
{
$base = preg_replace('/[^A-Za-z0-9 _.-]/u', '_', pathinfo((string) ($job['input_name'] ?? 'image'), PATHINFO_FILENAME)) ?: 'image';
return mb_substr($base, 0, 80) . '.' . $ext;
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Libraries\Pipeline;
use App\Libraries\Process;
/**
* PDF driver: qpdf (merge/split/pages/metadata), Ghostscript (compress),
* Poppler (rasterize + text extraction). Images->PDF uses GD to build
* pages and Ghostscript to assemble.
*/
final class Pdf implements DriverInterface
{
public function handle(array $job, array $tool, callable $update): array
{
return match ($job['operation']) {
'pdf_merge' => $this->merge($job, $update),
'pdf_split' => $this->split($job, $update, singlePage: false),
'pdf_extract_pages' => $this->extractPages($job, $update),
'pdf_compress' => $this->compress($job, $update),
'pdf_remove_metadata'=> $this->removeMetadata($job, $update),
'pdf_to_jpg' => $this->rasterize($job, $update, 'jpg'),
'pdf_to_png' => $this->rasterize($job, $update, 'png'),
'pdf_to_text' => $this->toText($job, $update),
'images_to_pdf' => $this->imagesToPdf($job, $update),
default => throw new \RuntimeException('Unsupported operation.'),
};
}
private function merge(array $job, callable $update): array
{
$extra = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
$files = [Ffmpeg::resolveInput($job)];
foreach ((array) $extra as $f) {
$files[] = Ffmpeg::storagePath((string) $f, \App\Libraries\Pipeline::incomingDir());
}
if (count($files) < 2) {
throw new \RuntimeException('Select at least two PDF files.');
}
[, $out] = self::output($job, 'pdf');
$update(20, 'merging');
$proc = Process::binary('qpdf', ['--empty', '--pages', ...$files, '--', $out], config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Merge failed — are all files valid PDFs?');
}
return ['file' => basename($out), 'name' => 'merged.pdf', 'ext' => 'pdf'];
}
private function split(array $job, callable $update, bool $singlePage): array
{
$input = Ffmpeg::resolveInput($job);
[$zipFile, $zipPath] = self::output($job, 'zip');
$tmpDir = sys_get_temp_dir() . '/tv_pdf_' . $job['id'];
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
// one PDF per page via qpdf page ranges
$info = Process::binary('pdfinfo', [$input], 30);
$pagesN = 0;
if ($info->run() && preg_match('/Pages:\s+(\d+)/', $info->out(), $m)) {
$pagesN = (int) $m[1];
}
if ($pagesN < 1) {
throw new \RuntimeException('Could not read the PDF.');
}
$update(10, 'splitting');
for ($p = 1; $p <= $pagesN; ++$p) {
$proc = Process::binary('qpdf', [$input, '--pages', '.', (string) $p, '--', rtrim($tmpDir, '/') . "/page_{$p}.pdf"], 120);
if (! $proc->run()) {
break;
}
}
$this->zipDirectory($zipPath, $tmpDir);
return ['file' => basename($zipPath), 'name' => 'split_pages.zip', 'ext' => 'zip'];
}
private function extractPages(array $job, callable $update): array
{
$input = Ffmpeg::resolveInput($job);
$range = (string) (json_decode($job['params'] ?? '{}', true)['pages'] ?? '');
if (! preg_match('/^[0-9,\- ]{1,60}$/', $range)) {
throw new \RuntimeException('Enter pages like 1-3 or 2,5,7.');
}
$normalized = str_replace(' ', '', $range);
[, $out] = self::output($job, 'pdf');
$update(25, 'extracting');
// expand "1-3" into individual page numbers for qpdf
$parts = [];
foreach (explode(',', $normalized) as $chunk) {
if (preg_match('/^(\d+)-(\d+)$/', $chunk, $m)) {
foreach (range((int) $m[1], (int) $m[2]) as $p) {
$parts[] = (string) $p;
}
} elseif (ctype_digit($chunk)) {
$parts[] = $chunk;
}
}
$proc = Process::binary('qpdf', ['--empty', '--pages', $input, ...$parts, '--', $out], 300);
if (! $proc->run()) {
throw new \RuntimeException('Extraction failed — check the page range.');
}
return ['file' => basename($out), 'name' => 'extracted.pdf', 'ext' => 'pdf'];
}
private function compress(array $job, callable $update): array
{
$input = Ffmpeg::resolveInput($job);
[, $out] = self::output($job, 'pdf');
$level = (string) (json_decode($job['params'] ?? '{}', true)['preset'] ?? 'ebook');
$valid = ['screen', 'ebook', 'printer', 'prepress'];
in_array($level, $valid, true) || $level = 'ebook';
$update(15, 'compressing');
$proc = Process::binary('gs', [
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.5', '-dPDFSETTINGS=/' . $level,
'-dNOPAUSE', '-dQUIET', '-dBATCH',
"-sOutputFile={$out}", $input,
], config('Site')->maxProcessingSeconds);
if (! $proc->run() || ! is_file($out) || filesize($out) === 0) {
throw new \RuntimeException('Compression failed.');
}
if (filesize($out) >= filesize($input)) {
// already optimized — deliver a byte-identical copy rather than bigger file
copy($input, $out);
}
return ['file' => basename($out), 'name' => 'compressed.pdf', 'ext' => 'pdf'];
}
private function removeMetadata(array $job, callable $update): array
{
$input = Ffmpeg::resolveInput($job);
[, $out] = self::output($job, 'pdf');
$update(30, 'removing metadata');
$proc = Process::binary('qpdf', ['--empty', '--pages', $input, '1-z', '--', $out], 300);
if (! $proc->run()) {
throw new \RuntimeException('Metadata removal failed.');
}
return ['file' => basename($out), 'name' => 'cleaned.pdf', 'ext' => 'pdf'];
}
private function rasterize(array $job, callable $update, string $format): array
{
$input = Ffmpeg::resolveInput($job);
$dpi = min(200, max(72, (int) (json_decode($job['params'] ?? '{}', true)['dpi'] ?? 150)));
$tmpDir = sys_get_temp_dir() . '/tv_pdf_' . $job['id'];
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
$update(10, 'rendering pages');
$proc = Process::binary('pdftoppm', [
"-{$format}", '-r', (string) $dpi, $input, rtrim($tmpDir, '/') . '/page',
], config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('Rendering failed — is this a valid PDF?');
}
[$zipFile, $zipPath] = self::output($job, 'zip');
$this->zipDirectory($zipPath, $tmpDir);
$update(90, 'packaging');
return ['file' => basename($zipPath), 'name' => "pdf_as_{$format}.zip", 'ext' => 'zip'];
}
private function toText(array $job, callable $update): array
{
$input = Ffmpeg::resolveInput($job);
[, $txtPath] = self::output($job, 'txt');
$update(30, 'extracting text');
$proc = Process::binary('pdftotext', ['-layout', $input, $txtPath], 300);
if (! $proc->run()) {
throw new \RuntimeException('Text extraction failed. Scanned PDFs contain images, not text.');
}
return ['file' => basename($txtPath), 'name' => 'document.txt', 'ext' => 'txt'];
}
private function imagesToPdf(array $job, callable $update): array
{
$extra = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
$files = [Ffmpeg::resolveInput($job)];
foreach ((array) $extra as $f) {
$files[] = Ffmpeg::storagePath((string) $f);
}
if (count(array_filter($files)) === 0) {
throw new \RuntimeException('Add at least one image.');
}
// normalize every image to an intermediate PDF page via ImageMagick
$update(15, 'building pages');
$pagePdfs = [];
$i = 0;
foreach ($files as $file) {
if (! is_file($file)) {
continue;
}
$pagePdf = sys_get_temp_dir() . "/tv_img2pdf_{$job['id']}_{$i}.pdf";
$proc = Process::binary('convert', [
$file, '-auto-orient', '-background', 'white', '-flatten', '-resize', '2480x3508>', $pagePdf,
], 120);
if ($proc->run()) {
$pagePdfs[] = $pagePdf;
}
++$i;
}
if ($pagePdfs === []) {
throw new \RuntimeException('The images could not be converted to PDF pages.');
}
[, $out] = self::output($job, 'pdf');
$proc = Process::binary('gs', [
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.5', '-dNOPAUSE', '-dQUIET', '-dBATCH',
"-sOutputFile={$out}", ...$pagePdfs,
], config('Site')->maxProcessingSeconds);
array_map('unlink', $pagePdfs);
if (! $proc->run()) {
throw new \RuntimeException('PDF assembly failed.');
}
return ['file' => basename($out), 'name' => 'images.pdf', 'ext' => 'pdf'];
}
private static function output(array $job, string $ext): array
{
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
}
private function zipDirectory(string $zipPath, string $dir): void
{
$zip = new \ZipArchive();
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
foreach (glob(rtrim($dir, '/') . '/*') ?: [] as $f) {
if (is_file($f) && filesize($f) > 0) {
$zip->addFile($f, basename($f));
}
}
$zip->close();
array_map('unlink', glob(rtrim($dir, '/') . '/*') ?: []);
@rmdir($dir);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Libraries\Pipeline;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
/**
* Renders QR codes server-side (PNG) via chillerlan/php-qrcode.
* Text-kind tool: input is `content`, options: size, level, margin.
*/
final class Qr implements DriverInterface
{
public function handle(array $job, array $tool = [], ?callable $update = null): array
{
$update ??= static function (int $p, string $stage): void {
};
$params = json_decode($job['params'] ?? '{}', true) ?: [];
$content = trim((string) ($params['content'] ?? ''));
if ($content === '') {
throw new \RuntimeException('Enter the text or URL to encode.');
}
if (mb_strlen($content) > 1000) {
throw new \RuntimeException('Content too long for a QR code (max 1000 characters).');
}
// error-correction: L ~7%, M ~15%, Q ~25%, H ~30%
$level = strtoupper((string) ($params['level'] ?? 'M'));
if (! in_array($level, ['L', 'M', 'Q', 'H'], true)) {
$level = 'M';
}
$size = (int) ($params['size'] ?? 300);
$size = min(1000, max(120, $size));
$options = new QROptions([
'outputType' => QRCode::OUTPUT_IMAGE_PNG,
'eccLevel' => constant(QRCode::class . '::ECC_' . $level),
'scale' => max(3, (int) round($size / 33)),
'imageBase64' => false,
'quality' => 90,
]);
[, $out] = self::output($job, 'png');
$png = (new QRCode($options))->render($content);
if (! str_starts_with((string) $png, "\x89PNG")) {
throw new \RuntimeException('QR rendering failed.');
}
file_put_contents($out, $png);
return ['file' => basename($out), 'name' => 'qr-code.png', 'ext' => 'png'];
}
private static function output(array $job, string $ext): array
{
$dir = \App\Libraries\Pipeline::storageDir();
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
return [$file, $dir . '/' . $file];
}
}
+249
View File
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace App\Libraries\Pipeline;
use App\Libraries\Process;
use App\Libraries\UrlGuard;
/**
* YouTube driver.
*
* Always available (no external service): thumbnail download, oEmbed
* metadata, URL analysis. Downloading video/audio requires yt-dlp AND
* the operator feature flag — when disabled the driver throws a clear
* user-facing message and tool pages show an honest notice up-front.
*/
final class Youtube implements DriverInterface
{
private const ID_PATTERN = '(?:[A-Za-z0-9_-]{11})';
/** @return string|null 11-char video id from any public URL form */
public static function extractId(string $url): ?string
{
$url = trim($url);
if (preg_match('/^' . self::ID_PATTERN . '$/', $url) === 1) {
return $url; // bare id
}
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
if (! preg_match('/(^|\.)(youtube\.com|youtu\.be|youtube-nocookie\.com)$/', $host)) {
return null;
}
if ($host === 'youtu.be') {
$id = trim(parse_url($url, PHP_URL_PATH) ?? '', '/');
return preg_match('/^' . self::ID_PATTERN . '$/', $id) === 1 ? $id : null;
}
parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query);
if (! empty($query['v']) && preg_match('/^' . self::ID_PATTERN . '$/', (string) $query['v'])) {
return (string) $query['v'];
}
if (preg_match('#/(?:embed|shorts|live|v)/(' . self::ID_PATTERN . ')#', $url, $m)) {
return $m[1];
}
return null;
}
public function handle(array $job, array $tool, callable $update): array
{
$params = json_decode($job['params'] ?? '{}', true) ?: [];
$videoId = self::extractId((string) ($params['url'] ?? ''));
if ($videoId === null) {
throw new \RuntimeException('Invalid YouTube URL.');
}
$update(10, 'fetching');
return match ($job['operation']) {
'yt_thumbnails' => $this->thumbnails($job, $videoId),
default => $this->withYtDlp($job, $params, $update),
};
}
/**
* Thumbnail pack: maxres/sd/hq/mq/default as a zip. Fetched only
* from Google-owned image hosts through the allowlist.
*/
private function thumbnails(array $job, string $videoId): array
{
$guard = new UrlGuard();
if (! $guard->isAllowedMediaHost('i.ytimg.com')) {
throw new \RuntimeException('Thumbnails are not fetchable on this server.');
}
$qualities = [
'maxresdefault' => 'Max resolution (1280x720+)',
'sddefault' => 'Standard definition (640x480)',
'hqdefault' => 'High quality (480x360)',
'mqdefault' => 'Medium quality (320x180)',
'default' => 'Thumbnail (120x90)',
];
$tmpDir = sys_get_temp_dir() . '/tv_thumb_' . $job['id'];
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
$found = 0;
foreach ($qualities as $key => $_label) {
$target = rtrim($tmpDir, '/') . "/{$key}.jpg";
$data = $this->httpGet("https://i.ytimg.com/vi/{$videoId}/{$key}.jpg");
if ($data !== null && strlen($data) > 1000) {
file_put_contents($target, $data);
++$found;
}
}
// webp variants too
foreach (['maxresdefault', 'hq720', 'sddefault', 'hqdefault'] as $key) {
$webpName = str_replace('.jpg', '.webp', $key) === $key ? $key . '.webp' : $key;
$data = $this->httpGet("https://i.ytimg.com/vi_webp/{$videoId}/{$webpName}");
if ($data !== null && strlen($data) > 1000) {
file_put_contents(rtrim($tmpDir, '/') . '/' . basename($webpName), $data);
++$found;
}
}
if ($found === 0) {
throw new \RuntimeException('No thumbnails were found for this video.');
}
[$zipFile, $zipPath] = [\App\Libraries\Pipeline::safeName($job['id'], 'zip'), \App\Libraries\Pipeline::storageDir() . '/' . \App\Libraries\Pipeline::safeName($job['id'], 'zip')];
$zip = new \ZipArchive();
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
foreach (glob(rtrim($tmpDir, '/') . '/*') ?: [] as $f) {
$zip->addFile($f, basename($f));
}
$zip->close();
array_map('unlink', glob(rtrim($tmpDir, '/') . '/*') ?: []);
@rmdir($tmpDir);
return ['file' => basename($zipPath), 'name' => "thumbnails_{$videoId}.zip", 'ext' => 'zip'];
}
/** Gated yt-dlp execution (audio/video download + conversion). */
private function withYtDlp(array $job, array $params, callable $update): array
{
$pipeline = service('pipeline');
if (! $pipeline->youtubeEnabled()) {
throw new \RuntimeException(
'Downloading from YouTube is currently disabled on this server. Please review the platform terms of use before enabling this capability.'
);
}
$format = strtolower((string) ($params['format'] ?? 'mp3'));
$url = 'https://www.youtube.com/watch?v=' . urlencode((string) self::extractId((string) ($params['url'] ?? '')));
$update(15, 'fetching');
$outTemplate = rtrim(\App\Libraries\Pipeline::incomingDir(), '/') . '/' . $job['id'] . '.%(ext)s';
$args = ['-f', 'bestaudio/best', '-o', $outTemplate, '--no-playlist', '--no-warnings', '--socket-timeout', '30'];
if (in_array($format, ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg'], true)) {
$codecMap = ['mp3' => 'mp3', 'wav' => 'wav', 'm4a' => 'aac', 'aac' => 'aac', 'flac' => 'flac', 'ogg' => 'vorbis'];
$args[] = '-x';
$args[] = '--audio-format';
$args[] = $codecMap[$format];
$ext = $format;
} else {
// best mp4-compatible stream
$args[] = '-f';
$args[] = 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b';
$args[] = '--merge-output-format';
$args[] = 'mp4';
$ext = 'mp4';
}
$args[] = $url;
$proc = Process::binary('yt-dlp', $args, config('Site')->maxProcessingSeconds);
if (! $proc->run()) {
throw new \RuntimeException('This video could not be downloaded. It may be private, region-locked or unavailable.');
}
$update(75, 'converting');
// find produced file
$produced = glob(rtrim(\App\Libraries\Pipeline::incomingDir(), '/') . '/' . $job['id'] . '.*') ?: [];
$produced = array_values(array_filter($produced, static fn ($f) => ! str_ends_with((string) $f, '.part')));
if ($produced === []) {
throw new \RuntimeException('The download did not produce a file.');
}
$source = $produced[0];
$outPath = \App\Libraries\Pipeline::storageDir() . '/' . \App\Libraries\Pipeline::safeName($job['id'], $ext);
if (pathinfo($source, PATHINFO_EXTENSION) === $ext) {
rename($source, $outPath);
} else {
$conv = Process::binary('ffmpeg', ['-y', '-i', $source, $outPath], config('Site')->maxProcessingSeconds);
@unlink($source);
if (! $conv->run()) {
throw new \RuntimeException('Conversion failed after download.');
}
}
return ['file' => basename($outPath), 'name' => 'download.' . $ext, 'ext' => $ext];
}
/**
* oEmbed metadata lookup — no API key, official public endpoint,
* SSRF-guarded host allowlist.
*
* @return array|null title/author/thumb or null
*/
public static function fetchInfo(string $videoId): ?array
{
$endpoint = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $videoId) . '&format=json';
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_USERAGENT => config('Site')->name . '/1.0',
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200 || ! is_string($body)) {
return null;
}
$data = json_decode($body, true);
if (! is_array($data) || empty($data['title'])) {
return null;
}
return [
'id' => $videoId,
'title' => (string) $data['title'],
'author' => (string) ($data['author_name'] ?? ''),
'author_url' => (string) ($data['author_url'] ?? ''),
'thumbnail' => (string) ($data['thumbnail_url'] ?? ('https://i.ytimg.com/vi/' . $videoId . '/hqdefault.jpg')),
'duration' => null, // oEmbed doesn't expose duration
'width' => (int) ($data['width'] ?? 0),
'height' => (int) ($data['height'] ?? 0),
];
}
private function httpGet(string $url): ?string
{
try {
(new UrlGuard())->check($url);
} catch (\InvalidArgumentException) {
return null;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_USERAGENT => config('Site')->name . '/1.0',
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return $status === 200 && is_string($body) ? $body : null;
}
}