Files
deepseek beaf0e1f37 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
2026-08-23 07:10:30 +00:00

250 lines
9.7 KiB
PHP

<?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;
}
}