- Implemented a YouTube channel importer.

Added [admin/youtube_import.php (line 
1)](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/youtube_import.php:1), 
which:
Accepts a channel URL, @handle, or UC... channel ID.
Scrapes the channel /videos page.
Extracts videos from ytInitialData.
Shows a preview with checkboxes.
Imports selected videos into videos and video_sources.
Stores each video as a canonical YouTube watch URL with mime_type = 
text/html, so the existing YouTube embed playback path handles it 
correctly.
Downloads scraped thumbnails into media/thumbs/ when possible.
Skips duplicates by matching existing remote YouTube URLs.
Also wired it into the admin navigation in [admin/index.php (line 
172)](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/index.php:172), 
[admin/add.php (line 
199)](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/add.php:199), 
[admin/edit.php (line 
267)](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/edit.php:267), 
and [admin/settings.php (line 
109)](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/settings.php:109). 
Updated [README.md (line 
81)](/Users/tyemeclifford/Documents/GH/mediaplayer/README.md:81) with 
usage notes.
This commit is contained in:
Ty Clifford
2026-06-24 12:49:22 -04:00
parent 75d5d39cda
commit 42725c0c03
6 changed files with 523 additions and 0 deletions
+5
View File
@@ -43,6 +43,7 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly.
│ ├── index.php ← Video list with search + pagination │ ├── index.php ← Video list with search + pagination
│ ├── add.php ← Add new video with uploaded or external URL sources │ ├── add.php ← Add new video with uploaded or external URL sources
│ ├── edit.php ← Edit video metadata + manage local/remote sources │ ├── edit.php ← Edit video metadata + manage local/remote sources
│ ├── youtube_import.php ← Scrape a YouTube channel and import videos
│ ├── settings.php ← Site settings, per-page count, password │ ├── settings.php ← Site settings, per-page count, password
│ ├── login.php ← Admin login │ ├── login.php ← Admin login
│ ├── logout.php ← Session destroy + redirect │ ├── logout.php ← Session destroy + redirect
@@ -75,6 +76,10 @@ 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. 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.
### 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.
### Public Search ### Public Search
The full catalogue page includes a public search for published videos only. Search matches video title, description, and slug; draft videos remain hidden. The full catalogue page includes a public search for published videos only. Search matches video title, description, and slug; draft videos remain hidden.
+4
View File
@@ -196,6 +196,10 @@ render_head('Add Video — Admin', '
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video Add Video
</a> </a>
<a href="youtube_import.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
YouTube Importer
</a>
<hr class="nav-divider"> <hr class="nav-divider">
<a href="settings.php"> <a href="settings.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
+4
View File
@@ -264,6 +264,10 @@ render_head('Edit Video — Admin', '
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video Add Video
</a> </a>
<a href="youtube_import.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
YouTube Importer
</a>
<hr class="nav-divider"> <hr class="nav-divider">
<a href="settings.php"> <a href="settings.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
+8
View File
@@ -169,6 +169,10 @@ render_head('Admin — TyClifford.com', '
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video Add Video
</a> </a>
<a href="youtube_import.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
YouTube Importer
</a>
<hr class="nav-divider"> <hr class="nav-divider">
<a href="settings.php"> <a href="settings.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
@@ -195,6 +199,10 @@ render_head('Admin — TyClifford.com', '
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Video Add Video
</a> </a>
<a href="youtube_import.php" class="btn btn-secondary btn-sm">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
Import YouTube
</a>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
+4
View File
@@ -106,6 +106,10 @@ render_head('Settings — Admin', '
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video Add Video
</a> </a>
<a href="youtube_import.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
YouTube Importer
</a>
<hr class="nav-divider"> <hr class="nav-divider">
<a href="settings.php" class="active"> <a href="settings.php" class="active">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
+498
View File
@@ -0,0 +1,498 @@
<?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['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_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_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_collect_renderers($node, array &$renderers): void {
if (!is_array($node)) return;
foreach (['videoRenderer', 'gridVideoRenderer'] as $key) {
if (isset($node[$key]) && is_array($node[$key]) && !empty($node[$key]['videoId'])) {
$renderers[] = $node[$key];
}
}
foreach ($node as $value) {
if (is_array($value)) yt_collect_renderers($value, $renderers);
}
}
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 [
'id' => $id,
'title' => $title,
'description' => implode("\n", $description_parts),
'duration' => yt_duration_seconds($duration_text),
'duration_text' => $duration_text,
'thumbnail_url' => yt_best_thumbnail($renderer),
'watch_url' => 'https://www.youtube.com/watch?v=' . rawurlencode($id),
];
}
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.'];
}
$fetch = yt_http_get($url);
if ($fetch['body'] === '' || $fetch['error']) {
return ['url' => $url, 'videos' => [], 'error' => 'Could not fetch YouTube channel page. ' . $fetch['error']];
}
$data = yt_extract_initial_data($fetch['body']);
if (!$data) {
return ['url' => $url, 'videos' => [], 'error' => 'Could not find ytInitialData in the scraped channel page. YouTube may have returned a consent or bot-check page.'];
}
$renderers = [];
yt_collect_renderers($data, $renderers);
$videos = [];
$seen = [];
foreach ($renderers as $renderer) {
$video = yt_video_from_renderer($renderer);
if (!$video || isset($seen[$video['id']])) continue;
$seen[$video['id']] = true;
$videos[] = $video;
if (count($videos) >= $limit) break;
}
if (!$videos) {
return ['url' => $url, 'videos' => [], 'error' => 'No videos were found on the scraped channel videos page.'];
}
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>