0f262cab3f
It now handles more YouTube layouts: Classic videoRenderer, gridVideoRenderer, and compactVideoRenderer Shorts-style reelItemRenderer Newer lockupViewModel channel grid objects Raw HTML fallback by extracting watch?v=, /shorts/, /embed/, and "videoId" matches A second scrape attempt using YouTube’s older grid query: view=0&sort=dd&flow=grid So instead of stopping at “no videos found” when YouTube changes the JSON shape, it now tries multiple extraction strategies and can still import proper YouTube embed URLs with fallback thumbnails.
686 lines
28 KiB
PHP
686 lines
28 KiB
PHP
<?php
|
|
require_once __DIR__ . '/auth.php';
|
|
require_once __DIR__ . '/../includes/layout.php';
|
|
admin_require_auth();
|
|
|
|
$db = get_db();
|
|
$error = '';
|
|
$msg = '';
|
|
$preview_videos = [];
|
|
$channel_input = trim($_POST['channel'] ?? '');
|
|
$limit = max(1, min(75, (int)($_POST['limit'] ?? 25)));
|
|
$published = isset($_POST['published']) || $_SERVER['REQUEST_METHOD'] !== 'POST';
|
|
|
|
function yt_http_get(string $url, int $timeout = 20): array {
|
|
$headers = [
|
|
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36',
|
|
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
|
'Accept-Language: en-US,en;q=0.9',
|
|
];
|
|
|
|
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 yt_channel_videos_url(string $input): string {
|
|
$input = trim($input);
|
|
if ($input === '') return '';
|
|
|
|
if (!preg_match('#^https?://#i', $input)) {
|
|
if (str_starts_with($input, '@')) {
|
|
return 'https://www.youtube.com/@' . rawurlencode(ltrim($input, '@')) . '/videos';
|
|
}
|
|
if (preg_match('/^UC[A-Za-z0-9_-]{20,}$/', $input)) {
|
|
return 'https://www.youtube.com/channel/' . rawurlencode($input) . '/videos';
|
|
}
|
|
return 'https://www.youtube.com/@' . rawurlencode(ltrim($input, '@/')) . '/videos';
|
|
}
|
|
|
|
$parts = @parse_url($input);
|
|
if (!is_array($parts) || empty($parts['host'])) return '';
|
|
$host = strtolower((string)$parts['host']);
|
|
if (!media_host_matches($host, 'youtube.com')) return '';
|
|
|
|
$path = '/' . trim((string)($parts['path'] ?? ''), '/');
|
|
if ($path === '/') return '';
|
|
if (preg_match('#^/watch#', $path)) return '';
|
|
|
|
if (!preg_match('#/videos/?$#', $path)) {
|
|
$path = rtrim($path, '/') . '/videos';
|
|
}
|
|
|
|
return 'https://www.youtube.com' . $path;
|
|
}
|
|
|
|
function yt_json_object_at(string $text, int $start): string {
|
|
$depth = 0;
|
|
$in_string = false;
|
|
$escaped = false;
|
|
$length = strlen($text);
|
|
|
|
for ($i = $start; $i < $length; $i++) {
|
|
$ch = $text[$i];
|
|
if ($in_string) {
|
|
if ($escaped) {
|
|
$escaped = false;
|
|
} elseif ($ch === '\\') {
|
|
$escaped = true;
|
|
} elseif ($ch === '"') {
|
|
$in_string = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($ch === '"') {
|
|
$in_string = true;
|
|
} elseif ($ch === '{') {
|
|
$depth++;
|
|
} elseif ($ch === '}') {
|
|
$depth--;
|
|
if ($depth === 0) {
|
|
return substr($text, $start, $i - $start + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function yt_extract_initial_data(string $html): ?array {
|
|
foreach (['var ytInitialData =', 'ytInitialData ='] as $marker) {
|
|
$pos = strpos($html, $marker);
|
|
if ($pos === false) continue;
|
|
$start = strpos($html, '{', $pos);
|
|
if ($start === false) continue;
|
|
$json = yt_json_object_at($html, $start);
|
|
if ($json === '') continue;
|
|
$data = json_decode($json, true);
|
|
if (is_array($data)) return $data;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function yt_text($value): string {
|
|
if (is_string($value)) return trim($value);
|
|
if (!is_array($value)) return '';
|
|
if (isset($value['simpleText'])) return trim((string)$value['simpleText']);
|
|
if (isset($value['content'])) return trim((string)$value['content']);
|
|
if (isset($value['text'])) return trim((string)$value['text']);
|
|
if (isset($value['label'])) return trim((string)$value['label']);
|
|
if (isset($value['accessibilityLabel'])) return trim((string)$value['accessibilityLabel']);
|
|
if (isset($value['runs']) && is_array($value['runs'])) {
|
|
$parts = [];
|
|
foreach ($value['runs'] as $run) {
|
|
if (isset($run['text'])) $parts[] = (string)$run['text'];
|
|
}
|
|
return trim(implode('', $parts));
|
|
}
|
|
if (isset($value['accessibility']['accessibilityData']['label'])) {
|
|
return trim((string)$value['accessibility']['accessibilityData']['label']);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function yt_video_id(string $value): string {
|
|
$value = trim($value);
|
|
if (preg_match('/^[A-Za-z0-9_-]{11}$/', $value)) return $value;
|
|
return '';
|
|
}
|
|
|
|
function yt_best_thumbnail(array $renderer): string {
|
|
$thumbs = $renderer['thumbnail']['thumbnails'] ?? [];
|
|
if (!is_array($thumbs) || empty($thumbs)) return '';
|
|
usort($thumbs, fn($a, $b) => (int)($b['width'] ?? 0) <=> (int)($a['width'] ?? 0));
|
|
return isset($thumbs[0]['url']) ? html_entity_decode((string)$thumbs[0]['url'], ENT_QUOTES | ENT_HTML5, 'UTF-8') : '';
|
|
}
|
|
|
|
function yt_collect_thumbnail_candidates($node, array &$candidates): void {
|
|
if (!is_array($node)) return;
|
|
|
|
if (isset($node['url']) && is_string($node['url'])) {
|
|
$url = html_entity_decode($node['url'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
if (preg_match('#https?://[^"\']*(?:ytimg|ggpht)[^"\']*#i', $url)) {
|
|
$candidates[] = [
|
|
'url' => $url,
|
|
'width' => (int)($node['width'] ?? 0),
|
|
'score' => str_contains($url, 'i.ytimg.com') ? 2 : 1,
|
|
];
|
|
}
|
|
}
|
|
|
|
foreach ($node as $value) {
|
|
if (is_array($value)) yt_collect_thumbnail_candidates($value, $candidates);
|
|
}
|
|
}
|
|
|
|
function yt_best_thumbnail_any(array $node, string $video_id = ''): string {
|
|
$candidates = [];
|
|
yt_collect_thumbnail_candidates($node, $candidates);
|
|
if ($candidates) {
|
|
usort($candidates, fn($a, $b) => (($b['score'] <=> $a['score']) ?: ($b['width'] <=> $a['width'])));
|
|
return $candidates[0]['url'];
|
|
}
|
|
|
|
return $video_id !== '' ? 'https://i.ytimg.com/vi/' . rawurlencode($video_id) . '/hqdefault.jpg' : '';
|
|
}
|
|
|
|
function yt_duration_seconds(string $duration): int {
|
|
$duration = trim($duration);
|
|
if ($duration === '' || stripos($duration, 'live') !== false) return 0;
|
|
if (preg_match('/^\d+(?::\d+)+$/', $duration)) {
|
|
$parts = array_map('intval', explode(':', $duration));
|
|
$seconds = 0;
|
|
foreach ($parts as $part) $seconds = ($seconds * 60) + $part;
|
|
return $seconds;
|
|
}
|
|
|
|
$seconds = 0;
|
|
if (preg_match('/(\d+)\s*h/i', $duration, $m)) $seconds += (int)$m[1] * 3600;
|
|
if (preg_match('/(\d+)\s*m/i', $duration, $m)) $seconds += (int)$m[1] * 60;
|
|
if (preg_match('/(\d+)\s*s/i', $duration, $m)) $seconds += (int)$m[1];
|
|
return $seconds;
|
|
}
|
|
|
|
function yt_renderer_duration(array $renderer): string {
|
|
$duration = yt_text($renderer['lengthText'] ?? []);
|
|
if ($duration !== '') return $duration;
|
|
foreach (($renderer['thumbnailOverlays'] ?? []) as $overlay) {
|
|
$text = yt_text($overlay['thumbnailOverlayTimeStatusRenderer']['text'] ?? []);
|
|
if ($text !== '') return $text;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function yt_video_record(string $id, string $title, string $description = '', string $duration_text = '', string $thumbnail_url = ''): ?array {
|
|
$id = yt_video_id($id);
|
|
$title = trim($title);
|
|
if ($id === '') return null;
|
|
if ($title === '') $title = 'YouTube Video ' . $id;
|
|
|
|
return [
|
|
'id' => $id,
|
|
'title' => $title,
|
|
'description' => trim($description),
|
|
'duration' => yt_duration_seconds($duration_text),
|
|
'duration_text' => trim($duration_text),
|
|
'thumbnail_url' => $thumbnail_url ?: 'https://i.ytimg.com/vi/' . rawurlencode($id) . '/hqdefault.jpg',
|
|
'watch_url' => 'https://www.youtube.com/watch?v=' . rawurlencode($id),
|
|
];
|
|
}
|
|
|
|
function yt_string_at_path(array $node, array $path): string {
|
|
$current = $node;
|
|
foreach ($path as $key) {
|
|
if (!is_array($current) || !array_key_exists($key, $current)) return '';
|
|
$current = $current[$key];
|
|
}
|
|
return yt_text($current);
|
|
}
|
|
|
|
function yt_find_first_text_by_key($node, array $keys): string {
|
|
if (!is_array($node)) return '';
|
|
foreach ($keys as $key) {
|
|
if (isset($node[$key])) {
|
|
$text = yt_text($node[$key]);
|
|
if ($text !== '') return $text;
|
|
}
|
|
}
|
|
foreach ($node as $value) {
|
|
if (is_array($value)) {
|
|
$text = yt_find_first_text_by_key($value, $keys);
|
|
if ($text !== '') return $text;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function yt_video_from_reel_renderer(array $renderer): ?array {
|
|
$id = yt_video_id((string)($renderer['videoId'] ?? ''));
|
|
if ($id === '') return null;
|
|
$title = yt_text($renderer['headline'] ?? []);
|
|
if ($title === '') $title = yt_text($renderer['accessibility']['accessibilityData']['label'] ?? []);
|
|
if ($title === '') $title = yt_find_first_text_by_key($renderer, ['title', 'headline']);
|
|
$views = yt_text($renderer['viewCountText'] ?? []);
|
|
$description = $views !== '' ? $views : '';
|
|
return yt_video_record($id, $title, $description, '', yt_best_thumbnail_any($renderer, $id));
|
|
}
|
|
|
|
function yt_video_from_lockup_model(array $model): ?array {
|
|
$id = yt_video_id((string)($model['contentId'] ?? $model['videoId'] ?? ''));
|
|
if ($id === '') {
|
|
$url = yt_find_first_text_by_key($model, ['url', 'webCommandMetadata']);
|
|
if (preg_match('/(?:watch\?v=|shorts\/)([A-Za-z0-9_-]{11})/', $url, $m)) $id = $m[1];
|
|
}
|
|
if ($id === '') return null;
|
|
|
|
$title = yt_string_at_path($model, ['metadata', 'lockupMetadataViewModel', 'title']);
|
|
if ($title === '') $title = yt_find_first_text_by_key($model, ['title', 'headline', 'contentTitle']);
|
|
|
|
$metadata = [];
|
|
foreach (['metadata', 'subtitle', 'secondarySubtitle', 'viewCountText', 'publishedTimeText'] as $key) {
|
|
$text = yt_find_first_text_by_key($model[$key] ?? [], ['content', 'simpleText', 'text']);
|
|
if ($text !== '') $metadata[] = $text;
|
|
}
|
|
|
|
$duration = yt_find_first_text_by_key($model, ['duration', 'lengthText', 'thumbnailOverlayTimeStatusRenderer']);
|
|
if ($duration !== '' && !preg_match('/\d/', $duration)) $duration = '';
|
|
|
|
return yt_video_record($id, $title, implode("\n", array_unique($metadata)), $duration, yt_best_thumbnail_any($model, $id));
|
|
}
|
|
|
|
function yt_add_video(array $video, array &$videos, array &$seen, int $limit): void {
|
|
if (isset($seen[$video['id']]) || count($videos) >= $limit) return;
|
|
$seen[$video['id']] = true;
|
|
$videos[] = $video;
|
|
}
|
|
|
|
function yt_collect_video_items($node, array &$videos, array &$seen, int $limit): void {
|
|
if (!is_array($node)) return;
|
|
if (count($videos) >= $limit) return;
|
|
|
|
foreach (['videoRenderer', 'gridVideoRenderer', 'compactVideoRenderer'] as $key) {
|
|
if (isset($node[$key]) && is_array($node[$key]) && !empty($node[$key]['videoId'])) {
|
|
$video = yt_video_from_renderer($node[$key]);
|
|
if ($video) yt_add_video($video, $videos, $seen, $limit);
|
|
}
|
|
}
|
|
|
|
if (isset($node['reelItemRenderer']) && is_array($node['reelItemRenderer'])) {
|
|
$video = yt_video_from_reel_renderer($node['reelItemRenderer']);
|
|
if ($video) yt_add_video($video, $videos, $seen, $limit);
|
|
}
|
|
|
|
if (isset($node['lockupViewModel']) && is_array($node['lockupViewModel'])) {
|
|
$video = yt_video_from_lockup_model($node['lockupViewModel']);
|
|
if ($video) yt_add_video($video, $videos, $seen, $limit);
|
|
}
|
|
|
|
foreach ($node as $value) {
|
|
if (is_array($value)) yt_collect_video_items($value, $videos, $seen, $limit);
|
|
if (count($videos) >= $limit) return;
|
|
}
|
|
}
|
|
|
|
function yt_video_from_renderer(array $renderer): ?array {
|
|
$id = media_clean_token((string)($renderer['videoId'] ?? ''));
|
|
if ($id === '') return null;
|
|
|
|
$title = yt_text($renderer['title'] ?? []);
|
|
if ($title === '') return null;
|
|
|
|
$duration_text = yt_renderer_duration($renderer);
|
|
$snippet = yt_text($renderer['descriptionSnippet'] ?? []);
|
|
$published = yt_text($renderer['publishedTimeText'] ?? []);
|
|
$views = yt_text($renderer['viewCountText'] ?? []);
|
|
$description_parts = array_filter([$snippet, trim($published . ($published && $views ? ' - ' : '') . $views)]);
|
|
|
|
return yt_video_record($id, $title, implode("\n", $description_parts), $duration_text, yt_best_thumbnail($renderer));
|
|
}
|
|
|
|
function yt_title_near_video_id(string $html, string $id): string {
|
|
$quoted_id = preg_quote($id, '/');
|
|
$patterns = [
|
|
'/"videoId":"' . $quoted_id . '".{0,5000}?"title":\{"runs":\[\{"text":"([^"]+)"/s',
|
|
'/"videoId":"' . $quoted_id . '".{0,5000}?"title":\{"simpleText":"([^"]+)"/s',
|
|
'/"title":\{"runs":\[\{"text":"([^"]+)"\}\]\}.{0,5000}?"videoId":"' . $quoted_id . '"/s',
|
|
'/"title":\{"simpleText":"([^"]+)"\}.{0,5000}?"videoId":"' . $quoted_id . '"/s',
|
|
];
|
|
foreach ($patterns as $pattern) {
|
|
if (preg_match($pattern, $html, $m)) {
|
|
$decoded = json_decode('"' . str_replace('"', '\"', $m[1]) . '"');
|
|
return is_string($decoded) ? $decoded : html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function yt_duration_near_video_id(string $html, string $id): string {
|
|
$quoted_id = preg_quote($id, '/');
|
|
$patterns = [
|
|
'/"videoId":"' . $quoted_id . '".{0,5000}?"lengthText":\{"[^}]*"simpleText":"([^"]+)"/s',
|
|
'/"lengthText":\{"[^}]*"simpleText":"([^"]+)".{0,5000}?"videoId":"' . $quoted_id . '"/s',
|
|
];
|
|
foreach ($patterns as $pattern) {
|
|
if (preg_match($pattern, $html, $m)) return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function yt_videos_from_html(string $html, int $limit): array {
|
|
preg_match_all('/(?:watch\?v=|shorts\/|embed\/|\"videoId\":\")([A-Za-z0-9_-]{11})/', $html, $matches);
|
|
$ids = array_values(array_unique($matches[1] ?? []));
|
|
$videos = [];
|
|
$seen = [];
|
|
foreach ($ids as $id) {
|
|
if (count($videos) >= $limit) break;
|
|
$title = yt_title_near_video_id($html, $id);
|
|
$duration = yt_duration_near_video_id($html, $id);
|
|
$video = yt_video_record($id, $title, '', $duration, 'https://i.ytimg.com/vi/' . rawurlencode($id) . '/hqdefault.jpg');
|
|
if ($video) yt_add_video($video, $videos, $seen, $limit);
|
|
}
|
|
return $videos;
|
|
}
|
|
|
|
function yt_channel_candidate_urls(string $url): array {
|
|
return array_values(array_unique([
|
|
$url,
|
|
media_append_query($url, ['view' => '0', 'sort' => 'dd', 'flow' => 'grid']),
|
|
]));
|
|
}
|
|
|
|
function yt_scrape_channel_videos(string $input, int $limit): array {
|
|
$url = yt_channel_videos_url($input);
|
|
if ($url === '') {
|
|
return ['url' => '', 'videos' => [], 'error' => 'Enter a YouTube channel URL, @handle, or UC channel ID.'];
|
|
}
|
|
|
|
$videos = [];
|
|
$seen = [];
|
|
$last_error = '';
|
|
$found_initial_data = false;
|
|
|
|
foreach (yt_channel_candidate_urls($url) as $candidate_url) {
|
|
$fetch = yt_http_get($candidate_url);
|
|
if ($fetch['body'] === '' || $fetch['error']) {
|
|
$last_error = 'Could not fetch YouTube channel page. ' . $fetch['error'];
|
|
continue;
|
|
}
|
|
|
|
$data = yt_extract_initial_data($fetch['body']);
|
|
if ($data) {
|
|
$found_initial_data = true;
|
|
yt_collect_video_items($data, $videos, $seen, $limit);
|
|
}
|
|
|
|
if (count($videos) < $limit) {
|
|
foreach (yt_videos_from_html($fetch['body'], $limit) as $video) {
|
|
yt_add_video($video, $videos, $seen, $limit);
|
|
}
|
|
}
|
|
|
|
if ($videos) break;
|
|
}
|
|
|
|
if (!$videos) {
|
|
if (!$found_initial_data) {
|
|
if ($last_error !== '') return ['url' => $url, 'videos' => [], 'error' => $last_error];
|
|
return ['url' => $url, 'videos' => [], 'error' => 'Could not find ytInitialData or watch links in the scraped page. YouTube may have returned a consent, age gate, or bot-check page.'];
|
|
}
|
|
return ['url' => $url, 'videos' => [], 'error' => 'No importable videos were found. The channel may have no public videos, or YouTube may have served an unsupported layout.'];
|
|
}
|
|
|
|
return ['url' => $url, 'videos' => $videos, 'error' => ''];
|
|
}
|
|
|
|
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 : '';
|
|
}
|
|
|
|
function yt_import_videos(PDO $db, array $videos, array $selected_ids, bool $published): array {
|
|
$selected = array_fill_keys($selected_ids, true);
|
|
$inserted = 0;
|
|
$skipped = 0;
|
|
$errors = [];
|
|
|
|
foreach ($videos as $video) {
|
|
if (!isset($selected[$video['id']])) continue;
|
|
|
|
$exists = $db->prepare("SELECT COUNT(*) FROM video_sources WHERE source_type='remote' AND source_url=?");
|
|
$exists->execute([$video['watch_url']]);
|
|
if ((int)$exists->fetchColumn() > 0) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$slug = make_slug($video['title']);
|
|
$thumbnail = yt_download_thumbnail($video['thumbnail_url'], $slug);
|
|
$db->prepare("
|
|
INSERT INTO videos (title, description, slug, thumbnail, duration, sort_order, published)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
")->execute([
|
|
$video['title'],
|
|
$video['description'],
|
|
$slug,
|
|
$thumbnail,
|
|
(int)$video['duration'],
|
|
0,
|
|
$published ? 1 : 0,
|
|
]);
|
|
$video_id = (int)$db->lastInsertId();
|
|
$db->prepare("
|
|
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
|
|
VALUES (?,?,?,?,?,?,?,?)
|
|
")->execute([
|
|
$video_id,
|
|
'YouTube',
|
|
'remote',
|
|
'',
|
|
$video['watch_url'],
|
|
source_storage_mime_from_url($video['watch_url']),
|
|
'',
|
|
0,
|
|
]);
|
|
$inserted++;
|
|
} catch (Throwable $e) {
|
|
$errors[] = $video['title'] . ': ' . $e->getMessage();
|
|
}
|
|
}
|
|
|
|
return ['inserted' => $inserted, 'skipped' => $skipped, 'errors' => $errors];
|
|
}
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$action = $_POST['action'] ?? 'preview';
|
|
$scrape = yt_scrape_channel_videos($channel_input, $limit);
|
|
$preview_videos = $scrape['videos'];
|
|
if ($scrape['error']) $error = $scrape['error'];
|
|
|
|
if ($action === 'import' && !$error) {
|
|
$selected_ids = $_POST['video_ids'] ?? [];
|
|
if (!is_array($selected_ids)) $selected_ids = [];
|
|
$selected_ids = array_values(array_filter(array_map('media_clean_token', $selected_ids)));
|
|
if (!$selected_ids) {
|
|
$error = 'Select at least one scraped video to import.';
|
|
} else {
|
|
$result = yt_import_videos($db, $preview_videos, $selected_ids, $published);
|
|
$msg = $result['inserted'] . ' imported';
|
|
if ($result['skipped']) $msg .= ', ' . $result['skipped'] . ' skipped as duplicates';
|
|
$msg .= '.';
|
|
if ($result['errors']) $error = implode(' ', $result['errors']);
|
|
}
|
|
}
|
|
}
|
|
|
|
render_head('YouTube Importer — Admin', '
|
|
.admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; }
|
|
@media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } }
|
|
.admin-nav { background:var(--surface); border:1px solid var(--border); border-radius:calc(var(--r)+2px); padding:1.2rem; display:flex; flex-direction:column; gap:.4rem; align-self:start; position:sticky; top:1rem; }
|
|
.admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; }
|
|
.admin-nav a { display:flex; align-items:center; gap:.6rem; padding:.55rem .75rem; border-radius:var(--r); font-size:.82rem; color:var(--text-2); text-decoration:none; transition:background .15s,color .15s; }
|
|
.admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); }
|
|
.admin-nav a.active { background:rgba(176,96,255,.12); color:var(--purple); }
|
|
.nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; }
|
|
.form-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
|
|
@media(min-width:720px) { .form-grid { grid-template-columns:minmax(0,1fr) 120px; } }
|
|
.section-heading { font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.15em; text-transform:uppercase; color:var(--text-3); margin-bottom:.75rem; }
|
|
.hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; line-height:1.5; }
|
|
.video-import-list { display:flex; flex-direction:column; gap:.75rem; }
|
|
.import-row { display:grid; grid-template-columns:auto 120px minmax(0,1fr); gap:.8rem; align-items:start; padding:.8rem; background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r); }
|
|
@media(max-width:640px) { .import-row { grid-template-columns:auto minmax(0,1fr); } .import-thumb { display:none; } }
|
|
.import-thumb { width:120px; aspect-ratio:16/9; object-fit:cover; border-radius:4px; background:#000; border:1px solid var(--border-2); }
|
|
.import-title { font-size:.86rem; font-weight:650; color:var(--text); line-height:1.35; }
|
|
.import-meta { font-family:"Share Tech Mono",monospace; font-size:.62rem; color:var(--text-3); margin-top:.25rem; word-break:break-all; }
|
|
.import-desc { font-size:.74rem; color:var(--text-3); line-height:1.45; margin-top:.35rem; }
|
|
');
|
|
?>
|
|
<main class="page" role="main">
|
|
<header class="topbar">
|
|
<div class="topbar-brand">
|
|
<div>
|
|
<div class="brand-name">Ty Clifford</div>
|
|
<div class="brand-sub">admin / youtube importer</div>
|
|
</div>
|
|
</div>
|
|
<nav class="topbar-social">
|
|
<a class="social-pill" href="../index.php" target="_blank">View Site</a>
|
|
<a class="social-pill" href="logout.php">Logout</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<?php if ($msg): ?>
|
|
<div class="notice notice-success"><?= h($msg) ?></div>
|
|
<?php endif; ?>
|
|
<?php if ($error): ?>
|
|
<div class="notice notice-error"><?= h($error) ?></div>
|
|
<?php endif; ?>
|
|
|
|
<div class="admin-layout">
|
|
<aside class="admin-nav">
|
|
<div class="admin-nav-label">Navigation</div>
|
|
<a href="index.php">Videos</a>
|
|
<a href="add.php">Add Video</a>
|
|
<a href="youtube_import.php" class="active">YouTube Importer</a>
|
|
<hr class="nav-divider">
|
|
<a href="settings.php">Settings</a>
|
|
</aside>
|
|
|
|
<div style="display:flex;flex-direction:column;gap:1rem;">
|
|
<div>
|
|
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">Scrape Channel</p>
|
|
<h1 style="font-size:1.05rem;font-weight:700;color:var(--text);">YouTube Video Importer</h1>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<p class="section-heading">Channel Source</p>
|
|
<form method="post" action="youtube_import.php">
|
|
<input type="hidden" name="action" value="preview">
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label class="form-label" for="channel">Channel URL, @handle, or channel ID</label>
|
|
<input class="form-input" type="text" id="channel" name="channel"
|
|
value="<?= h($channel_input) ?>"
|
|
placeholder="https://www.youtube.com/@channel/videos">
|
|
<span class="hint">Scrapes the channel videos page and imports videos as YouTube embed sources. No YouTube API key is used.</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="limit">Limit</label>
|
|
<input class="form-input" type="number" id="limit" name="limit" value="<?= (int)$limit ?>" min="1" max="75">
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:1rem;flex-wrap:wrap;">
|
|
<label style="display:flex;align-items:center;gap:.55rem;cursor:pointer;">
|
|
<input type="checkbox" name="published" value="1" <?= $published ? 'checked' : '' ?>>
|
|
<span class="form-label" style="margin:0">Publish imported videos</span>
|
|
</label>
|
|
<button class="btn btn-primary" type="submit">Scrape Videos</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<?php if ($preview_videos): ?>
|
|
<div class="card">
|
|
<p class="section-heading">Scraped Videos</p>
|
|
<p class="hint">Review the scraped results, uncheck anything you do not want, then import. Duplicate YouTube URLs are skipped.</p>
|
|
<form method="post" action="youtube_import.php">
|
|
<input type="hidden" name="action" value="import">
|
|
<input type="hidden" name="channel" value="<?= h($channel_input) ?>">
|
|
<input type="hidden" name="limit" value="<?= (int)$limit ?>">
|
|
<?php if ($published): ?><input type="hidden" name="published" value="1"><?php endif; ?>
|
|
<div class="video-import-list">
|
|
<?php foreach ($preview_videos as $video): ?>
|
|
<label class="import-row">
|
|
<input type="checkbox" name="video_ids[]" value="<?= h($video['id']) ?>" checked>
|
|
<?php if ($video['thumbnail_url']): ?>
|
|
<img class="import-thumb" src="<?= h($video['thumbnail_url']) ?>" alt="">
|
|
<?php else: ?>
|
|
<div class="import-thumb"></div>
|
|
<?php endif; ?>
|
|
<span>
|
|
<span class="import-title"><?= h($video['title']) ?></span>
|
|
<span class="import-meta"><?= h($video['watch_url']) ?><?= $video['duration_text'] ? ' / ' . h($video['duration_text']) : '' ?></span>
|
|
<?php if ($video['description']): ?>
|
|
<span class="import-desc"><?= h($video['description']) ?></span>
|
|
<?php endif; ?>
|
|
</span>
|
|
</label>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1rem;flex-wrap:wrap;">
|
|
<button class="btn btn-primary" type="submit">Import Selected</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
|
|
<?php render_footer(); ?>
|
|
</main>
|
|
</body>
|
|
</html>
|