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