diff --git a/README.md b/README.md index 4a03ae5..4b1b748 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly. External URLs must use `http://` or `https://`. Uploaded files and remote URLs can be mixed on the same video. Recognized provider URLs such as YouTube, Vimeo, Dailymotion, Twitch, Facebook, TikTok, Instagram, X/Twitter, SoundCloud, Spotify, Google Drive, Wistia, Streamable, and Loom render through their embed players. +If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server. + ### Importing YouTube Channels Use **Admin → YouTube Importer** to paste a YouTube channel URL, `@handle`, or `UC...` channel ID. The importer scrapes the channel's `/videos` page, extracts video data from `ytInitialData`, previews the results, and imports selected videos as remote YouTube embed sources. Duplicate YouTube watch URLs are skipped. diff --git a/admin/add.php b/admin/add.php index e7c1037..a40dc8c 100644 --- a/admin/add.php +++ b/admin/add.php @@ -13,6 +13,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $duration = (int)($_POST['duration'] ?? 0); $sort_order = (int)($_POST['sort_order'] ?? 0); $published = isset($_POST['published']) ? 1 : 0; + $auto_thumb = isset($_POST['auto_thumbnail']); if (!$title) { $error = 'Title is required.'; @@ -112,8 +113,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($thumbnail && file_exists(MEDIA_DIR . $thumbnail)) unlink(MEDIA_DIR . $thumbnail); $db->prepare("DELETE FROM videos WHERE id=?")->execute([$video_id]); } else { - header('Location: edit.php?id=' . $video_id . '&saved=1'); - exit; + if ($thumbnail === '' && $auto_thumb) { + $thumbnail = media_auto_thumbnail_for_video($video_id, $slug); + if ($thumbnail !== '') { + $db->prepare("UPDATE videos SET thumbnail=? WHERE id=?")->execute([$thumbnail, $video_id]); + } + } + + header('Location: edit.php?id=' . $video_id . '&saved=1'); + exit; } } } @@ -247,7 +255,11 @@ render_head('Add Video — Admin', ' - JPG, PNG, WebP or GIF. Shown as 16:9 preview. + + JPG, PNG, WebP or GIF. Auto-grab supports YouTube, Vimeo, Dailymotion and other oEmbed providers; local uploads use ffmpeg when available.
diff --git a/admin/youtube_import.php b/admin/youtube_import.php index 6d7b842..bf29420 100644 --- a/admin/youtube_import.php +++ b/admin/youtube_import.php @@ -464,20 +464,7 @@ function yt_scrape_channel_videos(string $input, int $limit): array { } function yt_download_thumbnail(string $url, string $slug): string { - if ($url === '') return ''; - $fetch = yt_http_get($url, 12); - if ($fetch['body'] === '' || $fetch['error']) return ''; - if (function_exists('getimagesizefromstring') && !@getimagesizefromstring($fetch['body'])) return ''; - - $content_type = strtolower((string)$fetch['content_type']); - $ext = 'jpg'; - if (str_contains($content_type, 'png')) $ext = 'png'; - elseif (str_contains($content_type, 'webp')) $ext = 'webp'; - - $thumb_dir = MEDIA_DIR . 'thumbs/'; - if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true); - $fname = $slug . '_yt_' . time() . '.' . $ext; - return file_put_contents($thumb_dir . $fname, $fetch['body']) !== false ? 'thumbs/' . $fname : ''; + return media_download_thumbnail($url, $slug, 'yt'); } function yt_import_videos(PDO $db, array $videos, array $selected_ids, bool $published): array { diff --git a/includes/db.php b/includes/db.php index dfcc9d9..328fc24 100644 --- a/includes/db.php +++ b/includes/db.php @@ -306,6 +306,70 @@ function media_append_query(string $base, array $params): string { return $base . (strpos($base, '?') === false ? '?' : '&') . $query; } +function media_http_get(string $url, int $timeout = 12): array { + $url = normalize_media_url($url); + if ($url === '') return ['body' => '', 'code' => 0, 'content_type' => '', 'error' => 'Invalid URL.']; + + $headers = [ + 'User-Agent: Mozilla/5.0 (compatible; TyCliffordMedia/1.0)', + 'Accept: image/avif,image/webp,image/png,image/jpeg,image/*;q=0.9,application/json;q=0.8,text/html;q=0.7,*/*;q=0.5', + ]; + + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_HEADER => true, + ]); + $raw = curl_exec($ch); + $err = curl_error($ch); + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $header_size = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $content_type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + + if (!is_string($raw) || $raw === '') { + return ['body' => '', 'code' => $code, 'content_type' => $content_type, 'error' => $err ?: 'Empty response.']; + } + + return [ + 'body' => substr($raw, $header_size), + 'code' => $code, + 'content_type' => $content_type, + 'error' => $code >= 400 ? 'HTTP ' . $code : '', + ]; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", $headers), + 'timeout' => $timeout, + 'ignore_errors' => true, + ], + ]); + $body = @file_get_contents($url, false, $context); + $response_headers = $http_response_header ?? []; + $code = 0; + $content_type = ''; + foreach ($response_headers as $header) { + if (preg_match('#^HTTP/\S+\s+(\d+)#', $header, $m)) $code = (int)$m[1]; + if (stripos($header, 'Content-Type:') === 0) $content_type = trim(substr($header, 13)); + } + + return [ + 'body' => is_string($body) ? $body : '', + 'code' => $code, + 'content_type' => $content_type, + 'error' => $code >= 400 ? 'HTTP ' . $code : (!is_string($body) ? 'Unable to fetch URL.' : ''), + ]; +} + function media_parse_youtube_time(string $value): int { $value = trim($value); if ($value === '') return 0; @@ -334,6 +398,170 @@ function media_twitch_parent_host(): string { return is_string($host) && $host !== '' ? $host : 'localhost'; } +function media_youtube_id_from_url(string $url): string { + $host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? '')); + $path = (string)(parse_url($url, PHP_URL_PATH) ?? ''); + $segments = media_path_segments($url); + $query = media_query_values($url); + + $id = ''; + if (media_host_matches($host, 'youtu.be') && isset($segments[0])) $id = $segments[0]; + if ($id === '' && isset($query['v'])) $id = media_scalar_string($query['v']); + if ($id === '' && preg_match('#/(?:embed|shorts|live|v)/([^/?#]+)#', $path, $m)) $id = $m[1]; + return media_clean_token($id); +} + +function media_provider_oembed_endpoint(string $url, string $host): string { + if (media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube-nocookie.com')) { + return 'https://www.youtube.com/oembed?format=json&url=' . rawurlencode($url); + } + if (media_host_matches($host, 'vimeo.com')) { + return 'https://vimeo.com/api/oembed.json?url=' . rawurlencode($url); + } + if (media_host_matches($host, 'dailymotion.com') || media_host_matches($host, 'dai.ly')) { + return 'https://www.dailymotion.com/services/oembed?url=' . rawurlencode($url); + } + if (media_host_matches($host, 'tiktok.com')) { + return 'https://www.tiktok.com/oembed?url=' . rawurlencode($url); + } + if (media_host_matches($host, 'soundcloud.com')) { + return 'https://soundcloud.com/oembed?format=json&url=' . rawurlencode($url); + } + if (media_host_matches($host, 'open.spotify.com')) { + return 'https://open.spotify.com/oembed?url=' . rawurlencode($url); + } + if (media_host_matches($host, 'wistia.com') || media_host_matches($host, 'wi.st')) { + return 'https://fast.wistia.com/oembed?url=' . rawurlencode($url); + } + return ''; +} + +function media_oembed_thumbnail_url(string $url, string $host): string { + $endpoint = media_provider_oembed_endpoint($url, $host); + if ($endpoint === '') return ''; + + $fetch = media_http_get($endpoint, 8); + if ($fetch['body'] === '' || $fetch['error'] !== '') return ''; + + $data = json_decode($fetch['body'], true); + if (!is_array($data)) return ''; + + $thumb = normalize_media_url((string)($data['thumbnail_url'] ?? '')); + return $thumb; +} + +function media_provider_thumbnail_url(string $url): string { + $url = normalize_media_url($url); + if ($url === '') return ''; + + $host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? '')); + $path = (string)(parse_url($url, PHP_URL_PATH) ?? ''); + $segments = media_path_segments($url); + + if (media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtube-nocookie.com')) { + $id = media_youtube_id_from_url($url); + return $id !== '' ? 'https://i.ytimg.com/vi/' . rawurlencode($id) . '/hqdefault.jpg' : ''; + } + + if (media_host_matches($host, 'dailymotion.com') || media_host_matches($host, 'dai.ly')) { + $id = ''; + if (media_host_matches($host, 'dai.ly') && isset($segments[0])) $id = $segments[0]; + if ($id === '' && preg_match('#/(?:embed/)?video/([^/?#_]+)#', $path, $m)) $id = $m[1]; + $id = media_clean_token($id); + if ($id !== '') return 'https://www.dailymotion.com/thumbnail/video/' . rawurlencode($id); + } + + return media_oembed_thumbnail_url($url, $host); +} + +function media_thumbnail_extension(string $url, string $content_type): string { + $content_type = strtolower($content_type); + if (str_contains($content_type, 'png')) return 'png'; + if (str_contains($content_type, 'webp')) return 'webp'; + if (str_contains($content_type, 'gif')) return 'gif'; + + $ext = media_extension_from_url($url); + return in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif'], true) ? ($ext === 'jpeg' ? 'jpg' : $ext) : 'jpg'; +} + +function media_download_thumbnail(string $url, string $slug, string $label = 'auto'): string { + $url = normalize_media_url($url); + if ($url === '') return ''; + + $fetch = media_http_get($url, 12); + if ($fetch['body'] === '' || $fetch['error'] !== '') return ''; + if (function_exists('getimagesizefromstring') && !@getimagesizefromstring($fetch['body'])) return ''; + + $thumb_dir = MEDIA_DIR . 'thumbs/'; + if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true); + + $label = media_clean_token($label); + if ($label === '') $label = 'auto'; + $ext = media_thumbnail_extension($url, (string)$fetch['content_type']); + $fname = $slug . '_' . $label . '_' . time() . '_' . substr(sha1($url), 0, 8) . '.' . $ext; + + return file_put_contents($thumb_dir . $fname, $fetch['body']) !== false ? 'thumbs/' . $fname : ''; +} + +function media_disabled_function(string $name): bool { + $disabled = array_map('trim', explode(',', (string)ini_get('disable_functions'))); + return in_array($name, $disabled, true); +} + +function media_ffmpeg_path(): string { + $configured = trim((string)getenv('FFMPEG_PATH')); + if ($configured !== '' && is_executable($configured)) return $configured; + + if (!function_exists('shell_exec') || media_disabled_function('shell_exec')) return ''; + $path = trim((string)@shell_exec('command -v ffmpeg 2>/dev/null')); + return $path !== '' && is_executable($path) ? $path : ''; +} + +function media_thumbnail_from_local_video(string $file_path, string $slug): string { + $ffmpeg = media_ffmpeg_path(); + if ($ffmpeg === '') return ''; + + $source = MEDIA_DIR . ltrim($file_path, '/'); + if ($file_path === '' || !is_file($source) || !is_readable($source)) return ''; + + $thumb_dir = MEDIA_DIR . 'thumbs/'; + if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true); + $fname = $slug . '_frame_' . time() . '_' . substr(sha1($file_path), 0, 8) . '.jpg'; + $target = $thumb_dir . $fname; + + if (!function_exists('exec') || media_disabled_function('exec')) return ''; + $cmd = escapeshellarg($ffmpeg) . ' -y -ss 00:00:01 -i ' . escapeshellarg($source) . ' -frames:v 1 -vf ' . escapeshellarg('scale=1280:-2') . ' ' . escapeshellarg($target) . ' 2>/dev/null'; + @exec($cmd, $output, $code); + + if ($code === 0 && is_file($target) && filesize($target) > 0 && (!function_exists('getimagesize') || @getimagesize($target))) { + return 'thumbs/' . $fname; + } + + if (is_file($target)) unlink($target); + return ''; +} + +function media_auto_thumbnail_for_video(int $video_id, string $slug): string { + $stmt = get_db()->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC, id ASC"); + $stmt->execute([$video_id]); + $sources = $stmt->fetchAll(); + + foreach ($sources as $source) { + if (!source_is_remote($source)) continue; + $thumb_url = media_provider_thumbnail_url($source['source_url'] ?? ''); + $thumbnail = media_download_thumbnail($thumb_url, $slug, 'auto'); + if ($thumbnail !== '') return $thumbnail; + } + + foreach ($sources as $source) { + if (!source_is_local($source)) continue; + $thumbnail = media_thumbnail_from_local_video($source['file_path'] ?? '', $slug); + if ($thumbnail !== '') return $thumbnail; + } + + return ''; +} + function media_provider_embed(string $url): ?array { $parts = @parse_url($url); if (!is_array($parts) || empty($parts['host'])) return null; @@ -344,12 +572,7 @@ function media_provider_embed(string $url): ?array { $query = media_query_values($url); if (media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtube-nocookie.com')) { - $id = ''; - if (media_host_matches($host, 'youtu.be') && isset($segments[0])) $id = $segments[0]; - if ($id === '' && isset($query['v'])) $id = media_scalar_string($query['v']); - if ($id === '' && preg_match('#/(?:embed|shorts|live|v)/([^/?#]+)#', $path, $m)) $id = $m[1]; - - $id = media_clean_token($id); + $id = media_youtube_id_from_url($url); if ($id !== '') { $params = ['rel' => '0', 'modestbranding' => '1', 'playsinline' => '1']; $start = isset($query['start'])