dc71d26c2a
What changed: Added shared YouTube publish-date scraping helpers in [includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php). New YouTube imports now set videos.created_at to the original YouTube publish date when available. Manual Add/Edit saves now also try to correct timestamps for YouTube-backed videos. Added Admin Dashboard → Upkeeping → Correct YouTube timestamps in [admin/index.php](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/index.php), with batch size control for backfilling existing records. Renamed the admin table column from Added to Date, since it may now reflect original publish date. Documented the behavior in [README.md](/Users/tyemeclifford/Documents/GH/mediaplayer/README.md).
390 lines
19 KiB
PHP
390 lines
19 KiB
PHP
<?php
|
|
require_once __DIR__ . '/auth.php';
|
|
require_once __DIR__ . '/../includes/layout.php';
|
|
admin_require_auth();
|
|
|
|
$db = get_db();
|
|
$error = '';
|
|
$msg = '';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$title = trim($_POST['title'] ?? '');
|
|
$description = trim($_POST['description'] ?? '');
|
|
$duration = (int)($_POST['duration'] ?? 0);
|
|
$sort_order = (int)($_POST['sort_order'] ?? 0);
|
|
$published = isset($_POST['published']) ? 1 : 0;
|
|
$auto_thumb = isset($_POST['auto_thumbnail']);
|
|
|
|
if (!$title) {
|
|
$error = 'Title is required.';
|
|
} else {
|
|
$slug = make_slug($title);
|
|
|
|
// Handle thumbnail upload
|
|
$thumbnail = '';
|
|
if (!empty($_FILES['thumbnail']['tmp_name'])) {
|
|
$ext = strtolower(pathinfo($_FILES['thumbnail']['name'], PATHINFO_EXTENSION));
|
|
$allowed = ['jpg','jpeg','png','webp','gif'];
|
|
if (in_array($ext, $allowed)) {
|
|
$thumb_dir = MEDIA_DIR . 'thumbs/';
|
|
if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true);
|
|
$fname = $slug . '_thumb_' . time() . '.' . $ext;
|
|
if (move_uploaded_file($_FILES['thumbnail']['tmp_name'], $thumb_dir . $fname)) {
|
|
$thumbnail = 'thumbs/' . $fname;
|
|
}
|
|
} else {
|
|
$error = 'Thumbnail must be JPG, PNG, WebP, or GIF.';
|
|
}
|
|
}
|
|
|
|
if (!$error) {
|
|
// Insert video
|
|
$db->prepare("
|
|
INSERT INTO videos (title, description, slug, thumbnail, duration, sort_order, published)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $published]);
|
|
|
|
$video_id = (int)$db->lastInsertId();
|
|
|
|
// Handle local upload and remote URL sources
|
|
$source_types = $_POST['source_type'] ?? [];
|
|
$source_labels = $_POST['source_label'] ?? [];
|
|
$source_quality = $_POST['source_quality'] ?? [];
|
|
$source_order = $_POST['source_sort'] ?? [];
|
|
$source_urls = $_POST['source_url'] ?? [];
|
|
$source_files = $_FILES['source_file'] ?? [];
|
|
|
|
$videos_dir = MEDIA_DIR . 'videos/';
|
|
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
|
|
|
|
$inserted_sources = 0;
|
|
$moved_files = [];
|
|
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi'];
|
|
|
|
$source_count = max(
|
|
count($source_types),
|
|
count($source_labels),
|
|
count($source_urls),
|
|
isset($source_files['tmp_name']) && is_array($source_files['tmp_name']) ? count($source_files['tmp_name']) : 0
|
|
);
|
|
|
|
for ($i = 0; $i < $source_count; $i++) {
|
|
$type = ($source_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local';
|
|
$label = trim($source_labels[$i] ?? '');
|
|
$quality = trim($source_quality[$i] ?? '');
|
|
$sort = (int)($source_order[$i] ?? $i);
|
|
|
|
if ($type === 'remote') {
|
|
$url = normalize_media_url($source_urls[$i] ?? '');
|
|
if ($url === '') {
|
|
if (trim($source_urls[$i] ?? '') !== '') $error = 'External video URLs must start with http:// or https://.';
|
|
continue;
|
|
}
|
|
|
|
$db->prepare("
|
|
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
|
|
VALUES (?,?,?,?,?,?,?,?)
|
|
")->execute([$video_id, $label, 'remote', '', $url, source_storage_mime_from_url($url), $quality, $sort]);
|
|
$inserted_sources++;
|
|
continue;
|
|
}
|
|
|
|
$tmp = $source_files['tmp_name'][$i] ?? '';
|
|
if (empty($tmp) || !is_uploaded_file($tmp)) continue;
|
|
$orig_name = $source_files['name'][$i] ?? '';
|
|
$ext = strtolower(pathinfo($orig_name, PATHINFO_EXTENSION));
|
|
if (!in_array($ext, $allowed_video, true)) continue;
|
|
|
|
$fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext;
|
|
if (move_uploaded_file($tmp, $videos_dir . $fname)) {
|
|
$moved_files[] = $videos_dir . $fname;
|
|
$db->prepare("
|
|
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
|
|
VALUES (?,?,?,?,?,?,?,?)
|
|
")->execute([$video_id, $label, 'local', 'videos/' . $fname, '', mime_from_ext($fname), $quality, $sort]);
|
|
$inserted_sources++;
|
|
}
|
|
}
|
|
|
|
if ($error) {
|
|
foreach ($moved_files as $moved) {
|
|
if (file_exists($moved)) unlink($moved);
|
|
}
|
|
if ($thumbnail && file_exists(MEDIA_DIR . $thumbnail)) unlink(MEDIA_DIR . $thumbnail);
|
|
$db->prepare("DELETE FROM videos WHERE id=?")->execute([$video_id]);
|
|
} else {
|
|
if ($thumbnail === '' && $auto_thumb) {
|
|
$thumbnail = media_auto_thumbnail_for_video($video_id, $slug);
|
|
if ($thumbnail !== '') {
|
|
$db->prepare("UPDATE videos SET thumbnail=? WHERE id=?")->execute([$thumbnail, $video_id]);
|
|
}
|
|
}
|
|
media_apply_youtube_timestamp($video_id);
|
|
|
|
header('Location: edit.php?id=' . $video_id . '&saved=1');
|
|
exit;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
render_head('Add Video — Admin', '
|
|
.form-grid { display:grid; grid-template-columns:1fr; gap:1.2rem; }
|
|
@media(min-width:700px) { .form-grid { grid-template-columns:1fr 1fr; } }
|
|
.form-grid .span2 { grid-column:1/-1; }
|
|
|
|
.sources-section { display:flex; flex-direction:column; gap:.85rem; }
|
|
.source-row {
|
|
background:var(--surface-2); border:1px solid var(--border);
|
|
border-radius:var(--r); padding:1rem;
|
|
display:grid; grid-template-columns:140px minmax(220px,1.5fr) 1fr 1fr 80px auto;
|
|
gap:.75rem; align-items:end;
|
|
}
|
|
@media(max-width:900px) { .source-row { grid-template-columns:1fr 1fr; } }
|
|
@media(max-width:560px) { .source-row { grid-template-columns:1fr; } }
|
|
.add-source-btn { align-self:flex-start; }
|
|
.source-location-panel[hidden] { display:none; }
|
|
|
|
.section-divider {
|
|
border:none; border-top:1px solid var(--border); margin:1.5rem 0 .5rem;
|
|
}
|
|
.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; }
|
|
|
|
.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; }
|
|
');
|
|
?>
|
|
<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 / add video</div>
|
|
</div>
|
|
</div>
|
|
<nav class="topbar-social">
|
|
<a class="social-pill" href="<?= h(public_absolute_url(public_home_url())) ?>" 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 ($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">
|
|
<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" 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="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>
|
|
|
|
<div>
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;flex-wrap:wrap;gap:.75rem;">
|
|
<div>
|
|
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">New Entry</p>
|
|
<h1 style="font-size:1.05rem;font-weight:700;color:var(--text);">Add Video</h1>
|
|
</div>
|
|
<a href="index.php" class="btn btn-secondary btn-sm">← Back</a>
|
|
</div>
|
|
|
|
<form method="post" action="add.php" enctype="multipart/form-data">
|
|
|
|
<!-- ── Video details ── -->
|
|
<div class="card" style="margin-bottom:1rem;">
|
|
<p class="section-heading">Video Details</p>
|
|
<div class="form-grid">
|
|
<div class="form-group span2">
|
|
<label class="form-label" for="title">Title *</label>
|
|
<input class="form-input" type="text" id="title" name="title"
|
|
value="<?= h($_POST['title'] ?? '') ?>" required placeholder="My Awesome Video">
|
|
</div>
|
|
<div class="form-group span2">
|
|
<label class="form-label" for="description">Description</label>
|
|
<textarea class="form-textarea" id="description" name="description" rows="3"><?= h($_POST['description'] ?? '') ?></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="duration">Duration (seconds)</label>
|
|
<input class="form-input" type="number" id="duration" name="duration"
|
|
value="<?= h($_POST['duration'] ?? '0') ?>" min="0" placeholder="0">
|
|
<span class="hint">e.g. 3600 = 1 hour. Leave 0 if unknown.</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="sort_order">Sort Order</label>
|
|
<input class="form-input" type="number" id="sort_order" name="sort_order"
|
|
value="<?= h($_POST['sort_order'] ?? '0') ?>" min="0">
|
|
<span class="hint">Lower numbers appear first.</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="thumbnail">Thumbnail Image</label>
|
|
<input class="form-input" type="file" id="thumbnail" name="thumbnail"
|
|
accept="image/jpeg,image/png,image/webp,image/gif" style="padding:.45rem .6rem;">
|
|
<label style="display:flex;align-items:center;gap:.55rem;margin-top:.55rem;cursor:pointer;">
|
|
<input type="checkbox" name="auto_thumbnail" value="1" <?= isset($_POST['auto_thumbnail']) || $_SERVER['REQUEST_METHOD'] !== 'POST' ? 'checked' : '' ?>>
|
|
<span style="font-size:.78rem;color:var(--text-2);">Auto-grab thumbnail from source if no image is uploaded</span>
|
|
</label>
|
|
<span class="hint">JPG, PNG, WebP or GIF. Auto-grab supports YouTube, Vimeo, Dailymotion and other oEmbed providers; local uploads use ffmpeg when available.</span>
|
|
</div>
|
|
<div class="form-group" style="justify-content:flex-end;padding-bottom:.2rem;">
|
|
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;">
|
|
<input type="checkbox" name="published" value="1" <?= isset($_POST['published']) || !isset($_POST['title']) ? 'checked' : '' ?>>
|
|
<span class="form-label" style="margin:0">Published</span>
|
|
</label>
|
|
<span class="hint">Uncheck to save as draft.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Video sources ── -->
|
|
<div class="card">
|
|
<p class="section-heading">Video Sources</p>
|
|
<p class="hint" style="margin-bottom:1rem;">
|
|
Upload a video file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo. The player will use a provider embed when the URL supports it.
|
|
Uploaded files: <strong>MP4, WebM, OGV, MOV, MKV, AVI</strong>. Remote URLs must use <strong>http://</strong> or <strong>https://</strong>.
|
|
</p>
|
|
|
|
<div class="sources-section" id="sources-container">
|
|
<!-- Row template (JS clones this) -->
|
|
<div class="source-row" data-source-row="0">
|
|
<div class="form-group">
|
|
<label class="form-label">Source</label>
|
|
<select class="form-select source-type-select" name="source_type[]">
|
|
<option value="local" selected>Upload</option>
|
|
<option value="remote">External URL / Embed</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group source-location-panel source-location-local">
|
|
<label class="form-label">Video File</label>
|
|
<input class="form-input" type="file" name="source_file[]"
|
|
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
|
|
style="padding:.45rem .6rem;">
|
|
</div>
|
|
<div class="form-group source-location-panel source-location-remote" hidden>
|
|
<label class="form-label">External URL</label>
|
|
<input class="form-input" type="url" name="source_url[]" placeholder="https://www.youtube.com/watch?v=...">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Quality Label</label>
|
|
<select class="form-select" name="source_quality[]">
|
|
<option value="1080p">1080p</option>
|
|
<option value="720p" selected>720p</option>
|
|
<option value="480p">480p</option>
|
|
<option value="360p">360p</option>
|
|
<option value="4K">4K</option>
|
|
<option value="">—</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Display Label</label>
|
|
<input class="form-input" type="text" name="source_label[]" placeholder="e.g. HD">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Sort</label>
|
|
<input class="form-input" type="number" name="source_sort[]" value="0" min="0" style="width:70px;">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="button" class="btn btn-secondary btn-sm add-source-btn" id="add-source" style="margin-top:.75rem;align-self:flex-start;">
|
|
<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 Another Source
|
|
</button>
|
|
</div>
|
|
|
|
<div style="display:flex;gap:.75rem;margin-top:1rem;flex-wrap:wrap;">
|
|
<button class="btn btn-primary" type="submit">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
|
|
Save Video
|
|
</button>
|
|
<a href="index.php" class="btn btn-secondary">Cancel</a>
|
|
</div>
|
|
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<?php render_footer(); ?>
|
|
</main>
|
|
|
|
<script>
|
|
function toggleSourceRow(row) {
|
|
var select = row.querySelector('.source-type-select');
|
|
var isRemote = select && select.value === 'remote';
|
|
row.querySelectorAll('.source-location-local').forEach(function(el){ el.hidden = isRemote; });
|
|
row.querySelectorAll('.source-location-remote').forEach(function(el){ el.hidden = !isRemote; });
|
|
}
|
|
|
|
document.querySelectorAll('[data-source-row]').forEach(function(row) {
|
|
toggleSourceRow(row);
|
|
var select = row.querySelector('.source-type-select');
|
|
if (select) select.addEventListener('change', function(){ toggleSourceRow(row); });
|
|
});
|
|
|
|
document.getElementById('add-source').addEventListener('click', function() {
|
|
var container = document.getElementById('sources-container');
|
|
var rows = container.querySelectorAll('[data-source-row]');
|
|
var idx = rows.length;
|
|
var first = rows[0].cloneNode(true);
|
|
first.setAttribute('data-source-row', idx);
|
|
// Clear file inputs (can't clone values for file inputs)
|
|
first.querySelectorAll('input[type="file"]').forEach(function(el){ el.value=''; });
|
|
first.querySelectorAll('input[type="text"]').forEach(function(el){ el.value=''; });
|
|
first.querySelectorAll('input[type="url"]').forEach(function(el){ el.value=''; });
|
|
first.querySelectorAll('input[type="number"]').forEach(function(el){ el.value=idx; });
|
|
first.querySelectorAll('.source-type-select').forEach(function(el){ el.value='local'; });
|
|
// Add remove button if not already there
|
|
if (!first.querySelector('.remove-row')) {
|
|
var btn = document.createElement('button');
|
|
btn.type='button';
|
|
btn.className='btn btn-danger btn-sm remove-row';
|
|
btn.style='align-self:flex-end';
|
|
btn.innerHTML='✕';
|
|
btn.onclick=function(){ this.closest('[data-source-row]').remove(); };
|
|
first.appendChild(btn);
|
|
}
|
|
toggleSourceRow(first);
|
|
var select = first.querySelector('.source-type-select');
|
|
if (select) select.addEventListener('change', function(){ toggleSourceRow(first); });
|
|
container.appendChild(first);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|