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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user