Files
mediaplayer/admin/youtube_import.php
T
Ty Clifford 1abe5218ca - Implemented configurable public URL rewriting.
What changed:
Added pretty URL helpers/default setting in 
[includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php).
Added root rewrite rules in 
[.htaccess](/Users/tyemeclifford/Documents/GH/mediaplayer/.htaccess):/v/<slug>
/all
/search/<query>

Added Admin → Settings “Public URL Style” toggle in 
[admin/settings.php](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/settings.php), 
defaulting to pretty URLs.
Updated public/player/catalogue/embed/admin preview links to use the 
configured URL style.
Added redirects from legacy ?v= and ?q= URLs to pretty routes when 
rewriting is enabled.
Documented deployment and fallback behavior in 
[README.md](/Users/tyemeclifford/Documents/GH/mediaplayer/README.md).
2026-06-24 13:16:55 -04:00

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="<?= h(public_absolute_url(public_home_url())) ?>" 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>