42725c0c03
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.
310 lines
16 KiB
PHP
310 lines
16 KiB
PHP
<?php
|
|
require_once __DIR__ . '/auth.php';
|
|
require_once __DIR__ . '/../includes/layout.php';
|
|
admin_require_auth();
|
|
|
|
$db = get_db();
|
|
|
|
// Handle quick-actions
|
|
$msg = ''; $msg_type = 'success';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$action = $_POST['action'] ?? '';
|
|
|
|
if ($action === 'delete' && !empty($_POST['id'])) {
|
|
$id = (int)$_POST['id'];
|
|
$v = get_video_by_id($id);
|
|
if ($v) {
|
|
// Remove local source files + thumbnail
|
|
foreach ($v['sources'] as $s) {
|
|
if (source_is_local($s)) {
|
|
$f = MEDIA_DIR . $s['file_path'];
|
|
if ($s['file_path'] && file_exists($f)) unlink($f);
|
|
}
|
|
}
|
|
if ($v['thumbnail']) {
|
|
$t = MEDIA_DIR . $v['thumbnail'];
|
|
if (file_exists($t)) unlink($t);
|
|
}
|
|
$db->prepare("DELETE FROM videos WHERE id=?")->execute([$id]);
|
|
$msg = 'Video deleted.';
|
|
}
|
|
}
|
|
|
|
if ($action === 'toggle_published' && !empty($_POST['id'])) {
|
|
$id = (int)$_POST['id'];
|
|
$row = $db->prepare("SELECT published FROM videos WHERE id=?");
|
|
$row->execute([$id]);
|
|
$cur = $row->fetchColumn();
|
|
$db->prepare("UPDATE videos SET published=? WHERE id=?")->execute([$cur ? 0 : 1, $id]);
|
|
$msg = 'Video ' . ($cur ? 'unpublished' : 'published') . '.';
|
|
}
|
|
}
|
|
|
|
// Search + pagination
|
|
$search = trim($_GET['q'] ?? '');
|
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
|
$per_page = (int)setting('catalogue_per_page', '10');
|
|
|
|
$where_sql = $search ? "WHERE v.title LIKE :q OR v.description LIKE :q" : '';
|
|
$bind = $search ? [':q' => '%' . $search . '%'] : [];
|
|
|
|
$total_stmt = $db->prepare("SELECT COUNT(*) FROM videos v $where_sql");
|
|
$total_stmt->execute($bind);
|
|
$total = (int)$total_stmt->fetchColumn();
|
|
$total_pages = max(1, (int)ceil($total / $per_page));
|
|
$page = min($page, $total_pages);
|
|
$offset = ($page - 1) * $per_page;
|
|
|
|
$list_stmt = $db->prepare("
|
|
SELECT v.id, v.title, v.slug, v.thumbnail, v.duration, v.published, v.sort_order,
|
|
v.created_at,
|
|
COUNT(vs.id) AS source_count,
|
|
SUM(CASE WHEN vs.source_type='remote' THEN 1 ELSE 0 END) AS remote_count
|
|
FROM videos v
|
|
LEFT JOIN video_sources vs ON vs.video_id = v.id
|
|
$where_sql
|
|
GROUP BY v.id
|
|
ORDER BY v.sort_order ASC, v.id DESC
|
|
LIMIT :limit OFFSET :offset
|
|
");
|
|
foreach ($bind as $k => $val) $list_stmt->bindValue($k, $val);
|
|
$list_stmt->bindValue(':limit', $per_page, PDO::PARAM_INT);
|
|
$list_stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
|
$list_stmt->execute();
|
|
$videos = $list_stmt->fetchAll();
|
|
|
|
$qs = fn($p) => '?' . http_build_query(array_filter(['q' => $search, 'page' => $p]));
|
|
|
|
render_head('Admin — TyClifford.com', '
|
|
.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); }
|
|
.admin-nav a svg { flex-shrink:0; }
|
|
.nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; }
|
|
|
|
.admin-main { display:flex; flex-direction:column; gap:1rem; }
|
|
.admin-toolbar { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; justify-content:space-between; }
|
|
.search-form { display:flex; gap:.5rem; flex:1; min-width:0; max-width:360px; }
|
|
.search-form input { flex:1; }
|
|
|
|
.video-table { width:100%; border-collapse:collapse; }
|
|
.video-table th, .video-table td {
|
|
padding:.65rem .85rem; text-align:left;
|
|
border-bottom:1px solid var(--border);
|
|
font-size:.82rem;
|
|
}
|
|
.video-table th {
|
|
font-family:"Share Tech Mono",monospace; font-size:.62rem; letter-spacing:.12em;
|
|
text-transform:uppercase; color:var(--text-3); background:var(--surface-2);
|
|
}
|
|
.video-table tr:hover td { background:rgba(255,255,255,.02); }
|
|
.video-table .thumb-cell { width:80px; }
|
|
.video-table .thumb-cell img { width:72px; height:40px; object-fit:cover; border-radius:4px; display:block; background:var(--surface-2); }
|
|
.video-table .thumb-cell .no-thumb-sm { width:72px; height:40px; border-radius:4px; background:var(--surface-2); display:flex; align-items:center; justify-content:center; font-family:"Share Tech Mono",monospace; font-size:.45rem; color:var(--text-3); }
|
|
.video-table .title-cell { max-width:250px; }
|
|
.video-table .title-cell a { color:var(--text); text-decoration:none; font-weight:600; }
|
|
.video-table .title-cell a:hover { color:var(--purple); }
|
|
.video-table .title-cell .slug { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); display:block; margin-top:.15rem; }
|
|
.video-table .actions-cell { white-space:nowrap; }
|
|
.badge { display:inline-flex; align-items:center; padding:.18em .55em; border-radius:99px; font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.08em; }
|
|
.badge-green { background:rgba(0,232,122,.12); color:var(--green); border:1px solid rgba(0,232,122,.25); }
|
|
.badge-gray { background:rgba(255,255,255,.05); color:var(--text-3); border:1px solid var(--border-2); }
|
|
|
|
.table-wrap { overflow-x:auto; background:var(--surface); border:1px solid var(--border); border-radius:calc(var(--r)+2px); }
|
|
|
|
.admin-footer-bar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
|
.rec-count { font-family:"Share Tech Mono",monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; }
|
|
');
|
|
?>
|
|
<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 / dashboard</div>
|
|
</div>
|
|
</div>
|
|
<nav class="topbar-social">
|
|
<a class="social-pill" href="../index.php" target="_blank">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
View Site
|
|
</a>
|
|
<a class="social-pill" href="logout.php">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16,17 21,12 16,7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
|
Logout
|
|
</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<?php if ($msg): ?>
|
|
<div class="notice notice-<?= $msg_type ?>"><?= h($msg) ?></div>
|
|
<?php endif; ?>
|
|
|
|
<div class="admin-layout">
|
|
|
|
<!-- Sidebar nav -->
|
|
<aside class="admin-nav">
|
|
<div class="admin-nav-label">Navigation</div>
|
|
<a href="index.php" class="active">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
|
Videos
|
|
</a>
|
|
<a href="add.php">
|
|
<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>
|
|
Settings
|
|
</a>
|
|
</aside>
|
|
|
|
<!-- Main content -->
|
|
<div class="admin-main">
|
|
|
|
<div class="admin-toolbar">
|
|
<form class="search-form" method="get" action="index.php">
|
|
<input class="form-input" type="search" name="q"
|
|
placeholder="Search videos…" value="<?= h($search) ?>">
|
|
<button class="btn btn-secondary btn-sm" type="submit">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
Search
|
|
</button>
|
|
<?php if ($search): ?>
|
|
<a href="index.php" class="btn btn-secondary btn-sm">✕ Clear</a>
|
|
<?php endif; ?>
|
|
</form>
|
|
<a href="add.php" class="btn btn-primary btn-sm">
|
|
<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">
|
|
<table class="video-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="thumb-cell">Thumb</th>
|
|
<th class="title-cell">Title / Slug</th>
|
|
<th>Duration</th>
|
|
<th>Sources</th>
|
|
<th>Status</th>
|
|
<th>Added</th>
|
|
<th class="actions-cell">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php if (empty($videos)): ?>
|
|
<tr><td colspan="7" style="text-align:center;padding:3rem;font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
|
|
<?= $search ? 'No videos match "' . h($search) . '"' : 'No videos yet. <a href="add.php" style="color:var(--purple)">Add one!</a>' ?>
|
|
</td></tr>
|
|
<?php else: ?>
|
|
<?php foreach ($videos as $v): ?>
|
|
<tr>
|
|
<td class="thumb-cell">
|
|
<?php if ($v['thumbnail']): ?>
|
|
<img src="<?= h('../' . MEDIA_URL . $v['thumbnail']) ?>" alt="">
|
|
<?php else: ?>
|
|
<div class="no-thumb-sm">NO IMG</div>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td class="title-cell">
|
|
<a href="edit.php?id=<?= $v['id'] ?>"><?= h($v['title']) ?></a>
|
|
<span class="slug">/<?= h($v['slug']) ?></span>
|
|
</td>
|
|
<td style="font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
|
|
<?= $v['duration'] ? format_duration((int)$v['duration']) : '—' ?>
|
|
</td>
|
|
<td style="font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-2);">
|
|
<?= $v['source_count'] ?> source<?= $v['source_count'] != 1 ? 's' : '' ?>
|
|
<?php if ((int)$v['remote_count'] > 0): ?>
|
|
<span style="color:var(--text-3);"> · <?= (int)$v['remote_count'] ?> URL<?= (int)$v['remote_count'] != 1 ? 's' : '' ?></span>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td>
|
|
<span class="badge <?= $v['published'] ? 'badge-green' : 'badge-gray' ?>">
|
|
<?= $v['published'] ? 'Published' : 'Draft' ?>
|
|
</span>
|
|
</td>
|
|
<td style="font-family:'Share Tech Mono',monospace;font-size:.65rem;color:var(--text-3);">
|
|
<?= substr($v['created_at'], 0, 10) ?>
|
|
</td>
|
|
<td class="actions-cell">
|
|
<div style="display:flex;gap:.4rem;align-items:center;">
|
|
<a href="edit.php?id=<?= $v['id'] ?>" class="btn btn-secondary btn-sm" title="Edit">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
Edit
|
|
</a>
|
|
<form method="post" style="display:inline" onsubmit="return confirm('Delete this video and all its files?')">
|
|
<input type="hidden" name="action" value="delete">
|
|
<input type="hidden" name="id" value="<?= $v['id'] ?>">
|
|
<button class="btn btn-danger btn-sm" type="submit" title="Delete">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
|
</button>
|
|
</form>
|
|
<form method="post" style="display:inline">
|
|
<input type="hidden" name="action" value="toggle_published">
|
|
<input type="hidden" name="id" value="<?= $v['id'] ?>">
|
|
<button class="btn btn-secondary btn-sm" type="submit" title="<?= $v['published'] ? 'Unpublish' : 'Publish' ?>">
|
|
<?php if ($v['published']): ?>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
|
<?php else: ?>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
<?php endif; ?>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<?php if ($total_pages > 1 || $total > 0): ?>
|
|
<div class="admin-footer-bar">
|
|
<span class="rec-count">
|
|
<?= $total ?> video<?= $total != 1 ? 's' : '' ?>
|
|
<?= $search ? '· filtered' : '' ?>
|
|
· page <?= $page ?> of <?= $total_pages ?>
|
|
</span>
|
|
<?php
|
|
render_pagination($page, $total_pages, 'index.php?' . ($search ? 'q=' . urlencode($search) . '&' : '') . 'page=%d');
|
|
?>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
</div><!-- /admin-main -->
|
|
</div><!-- /admin-layout -->
|
|
|
|
<?php render_footer(); ?>
|
|
</main>
|
|
</body>
|
|
</html>
|