Files
mediaplayer/admin/add.php
T
Ty Clifford 2d52255b93 - What changed:
Added external_view_count so provider totals from YouTube/Vimeo/etc are 
added to the site’s total without double-counting local plays.
Updated public totals and per-video public view displays to use site 
views + external views.
Changed Upkeeping action to Correct external view counts with a Correct 
Views button.
Expanded provider scraping beyond YouTube with generic 
page/JSON-LD/oEmbed patterns for readable Vimeo, Dailymotion, and 
similar pages.
Improved Add Video:Title no longer has to be manually entered first.
External URL rows now have Fetch Info.
Metadata can fill title, description, duration, source label, and 
external view count.
Save also retries metadata extraction server-side.

Added authenticated metadata endpoint: 
[admin/media_info.php](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/media_info.php).
Updated docs in 
[README.md](/Users/tyemeclifford/Documents/GH/mediaplayer/README.md).
2026-06-25 04:40:59 -04:00

493 lines
24 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']);
$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'] ?? [];
$source_metadata = [];
$initial_external_view_count = 0;
$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';
if ($type !== 'remote') continue;
$url = normalize_media_url($source_urls[$i] ?? '');
if ($url === '') continue;
$metadata = media_external_metadata($url);
$source_metadata[$url] = $metadata;
if ($title === '' && $metadata['title'] !== '') $title = $metadata['title'];
if ($description === '' && $metadata['description'] !== '') $description = $metadata['description'];
if ($duration <= 0 && (int)$metadata['duration'] > 0) $duration = (int)$metadata['duration'];
if ((int)$metadata['view_count'] > $initial_external_view_count) {
$initial_external_view_count = (int)$metadata['view_count'];
}
}
$_POST['title'] = $title;
$_POST['description'] = $description;
$_POST['duration'] = (string)$duration;
if (!$title) {
$error = 'Title is required. If you use an external URL, click Fetch Info or leave the title blank and make sure the URL is readable.';
} 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, external_view_count, sort_order, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
")->execute([$title, $description, $slug, $thumbnail, $duration, $initial_external_view_count, $sort_order, $published]);
$video_id = (int)$db->lastInsertId();
// Handle local upload and remote URL sources
$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'];
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;
}
$metadata = $source_metadata[$url] ?? [];
if ($label === '' && !empty($metadata['provider'])) {
$label = ucwords(str_replace('-', ' ', (string)$metadata['provider']));
}
$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; }
.source-url-control { display:flex; gap:.45rem; align-items:center; }
.source-url-control input { min-width:0; }
.metadata-status { font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); min-height:1em; }
.metadata-status.success { color:var(--green); }
.metadata-status.error { color:var(--red); }
.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'] ?? '') ?>" placeholder="Auto-filled from external URL when available">
<span class="hint">Leave blank when pasting a supported external URL, then use Fetch Info.</span>
</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>
<div class="source-url-control">
<input class="form-input source-url-input" type="url" name="source_url[]" placeholder="https://www.youtube.com/watch?v=...">
<button type="button" class="btn btn-secondary btn-sm fetch-info-btn">Fetch Info</button>
</div>
<span class="metadata-status" aria-live="polite"></span>
</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; });
}
function providerLabel(value) {
if (!value) return '';
return value.replace(/-/g, ' ').replace(/\b\w/g, function(ch){ return ch.toUpperCase(); });
}
function setMetadataStatus(row, text, type) {
var status = row.querySelector('.metadata-status');
if (!status) return;
status.textContent = text || '';
status.classList.remove('success', 'error');
if (type) status.classList.add(type);
}
function fillFromMetadata(row, metadata, force) {
if (!metadata) return;
var title = document.getElementById('title');
var description = document.getElementById('description');
var duration = document.getElementById('duration');
var label = row.querySelector('input[name="source_label[]"]');
if (metadata.title && title && (force || !title.value.trim())) title.value = metadata.title;
if (metadata.description && description && (force || !description.value.trim())) description.value = metadata.description;
if (metadata.duration && duration && (force || !parseInt(duration.value || '0', 10))) duration.value = metadata.duration;
if (label && !label.value.trim()) label.value = providerLabel(metadata.provider) || metadata.title || 'External';
}
function fetchInfoForRow(row, force) {
var input = row.querySelector('.source-url-input');
var url = input ? input.value.trim() : '';
if (!url) return;
setMetadataStatus(row, 'Fetching media info...', '');
fetch('media_info.php?url=' + encodeURIComponent(url), { credentials: 'same-origin' })
.then(function(response) {
if (!response.ok) throw new Error('Request failed');
return response.json();
})
.then(function(data) {
if (!data.ok) {
setMetadataStatus(row, data.error || 'No metadata found.', 'error');
return;
}
fillFromMetadata(row, data.metadata, force);
var bits = [];
if (data.metadata.provider) bits.push(providerLabel(data.metadata.provider));
if (data.metadata.view_count) bits.push(Number(data.metadata.view_count).toLocaleString() + ' external views');
setMetadataStatus(row, bits.length ? bits.join(' · ') : 'Media info loaded.', 'success');
})
.catch(function() {
setMetadataStatus(row, 'Unable to fetch media info.', 'error');
});
}
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('sources-container').addEventListener('click', function(event) {
var btn = event.target.closest('.fetch-info-btn');
if (!btn) return;
fetchInfoForRow(btn.closest('[data-source-row]'), true);
});
document.getElementById('sources-container').addEventListener('blur', function(event) {
if (!event.target.classList.contains('source-url-input')) return;
var title = document.getElementById('title');
if (title && title.value.trim()) return;
fetchInfoForRow(event.target.closest('[data-source-row]'), false);
}, true);
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('.metadata-status').forEach(function(el){ el.textContent=''; el.classList.remove('success','error'); });
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>