- 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:
@@ -43,6 +43,7 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly.
|
||||
│ ├── index.php ← Video list with search + pagination
|
||||
│ ├── add.php ← Add new video with uploaded or external URL 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
|
||||
│ ├── login.php ← Admin login
|
||||
│ ├── 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.
|
||||
|
||||
### 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
|
||||
|
||||
The full catalogue page includes a public search for published videos only. Search matches video title, description, and slug; draft videos remain hidden.
|
||||
|
||||
@@ -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>
|
||||
Add Video
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
Add Video
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
Add Video
|
||||
</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">
|
||||
<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>
|
||||
@@ -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>
|
||||
Add Video
|
||||
</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 class="table-wrap">
|
||||
|
||||
@@ -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>
|
||||
Add Video
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user