- 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
250 lines
9.7 KiB
PHP
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;
|
|
}
|
|
}
|