- 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).
This commit is contained in:
Ty Clifford
2026-06-25 04:40:59 -04:00
parent 86d1fedcb3
commit 2d52255b93
8 changed files with 386 additions and 50 deletions
+124 -21
View File
@@ -14,9 +14,45 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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.';
$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);
@@ -40,20 +76,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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]);
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
$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);
@@ -61,13 +90,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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] ?? '');
@@ -80,6 +102,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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)
@@ -144,6 +170,11 @@ render_head('Add Video — Admin', '
@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;
@@ -232,9 +263,10 @@ render_head('Add Video — Admin', '
<p class="section-heading">Video Details</p>
<div class="form-grid">
<div class="form-group span2">
<label class="form-label" for="title">Title *</label>
<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">
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>
@@ -298,7 +330,11 @@ render_head('Add Video — Admin', '
</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 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>
@@ -351,12 +387,78 @@ function toggleSourceRow(row) {
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]');
@@ -368,6 +470,7 @@ document.getElementById('add-source').addEventListener('click', function() {
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')) {
+11 -4
View File
@@ -43,6 +43,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0);
$view_count = max(0, (int)($_POST['view_count'] ?? $video['view_count']));
$external_view_count = max(0, (int)($_POST['external_view_count'] ?? $video['external_view_count']));
$published = isset($_POST['published']) ? 1 : 0;
$auto_thumb = isset($_POST['auto_thumbnail']);
@@ -75,10 +76,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$error) {
$db->prepare("
UPDATE videos SET title=?, description=?, slug=?, thumbnail=?,
duration=?, sort_order=?, view_count=?, published=?,
duration=?, sort_order=?, view_count=?, external_view_count=?, published=?,
updated_at=datetime('now')
WHERE id=?
")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $view_count, $published, $id]);
")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $view_count, $external_view_count, $published, $id]);
// Update existing sources sort/label/quality
$upd_labels = $_POST['src_label'] ?? [];
@@ -330,10 +331,16 @@ render_head('Edit Video — Admin', '
value="<?= (int)$video['sort_order'] ?>" min="0">
</div>
<div class="form-group">
<label class="form-label" for="view_count">View Count</label>
<label class="form-label" for="view_count">Site View Count</label>
<input class="form-input" type="number" id="view_count" name="view_count"
value="<?= (int)$video['view_count'] ?>" min="0">
<span class="hint">Use this to restore older local videos or override an imported external count.</span>
<span class="hint">Local media-site plays. Use this to restore older local videos manually.</span>
</div>
<div class="form-group">
<label class="form-label" for="external_view_count">External View Count</label>
<input class="form-input" type="number" id="external_view_count" name="external_view_count"
value="<?= (int)$video['external_view_count'] ?>" min="0">
<span class="hint">Provider views from YouTube, Vimeo, and other readable external pages. Upkeeping can correct this automatically.</span>
</div>
<div class="form-group">
<label class="form-label">Published</label>
+12 -9
View File
@@ -50,11 +50,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$msg .= '.';
}
if ($action === 'restore_external_view_counts') {
if ($action === 'restore_external_view_counts' || $action === 'correct_external_view_counts') {
$limit = max(1, min(100, (int)($_POST['external_view_limit'] ?? 25)));
$result = media_restore_external_view_counts($limit);
$result = media_correct_external_view_counts($limit);
$msg = 'Upkeeping checked ' . $result['checked'] . ' external video' . ($result['checked'] === 1 ? '' : 's') . ': ';
$msg .= $result['restored'] . ' restored';
$msg .= $result['corrected'] . ' corrected';
if ($result['already_current']) $msg .= ', ' . $result['already_current'] . ' already current';
if ($result['not_found']) $msg .= ', ' . $result['not_found'] . ' without a readable external count';
$msg .= '.';
@@ -78,7 +78,7 @@ $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, v.view_count,
v.created_at, v.view_count, v.external_view_count,
COUNT(vs.id) AS source_count,
SUM(CASE WHEN vs.source_type='remote' THEN 1 ELSE 0 END) AS remote_count
FROM videos v
@@ -238,21 +238,21 @@ render_head('Admin — TyClifford.com', '
</div>
<div class="upkeep-row">
<div class="upkeep-copy">
<h2 class="card-title">Restore external view counts</h2>
<h2 class="card-title">Correct external view counts</h2>
<p class="hint">
Scrapes readable counts from supported external providers and stores the higher value between the provider and local database.
Scrapes readable counts from supported providers and adds them to the media-site totals alongside local plays.
<?= $external_view_candidates ?> external video<?= $external_view_candidates === 1 ? '' : 's' ?> can be checked.
Current database total: <?= h(view_count_label((int)$admin_total_views)) ?>.
</p>
</div>
<form class="upkeep-form" method="post" action="index.php">
<input type="hidden" name="action" value="restore_external_view_counts">
<input type="hidden" name="action" value="correct_external_view_counts">
<div class="form-group">
<label class="form-label" for="external_view_limit">Batch Size</label>
<input class="form-input" type="number" id="external_view_limit" name="external_view_limit" value="25" min="1" max="100">
</div>
<button class="btn btn-secondary btn-sm" type="submit">
Restore Views
Correct Views
</button>
</form>
</div>
@@ -324,7 +324,10 @@ render_head('Admin — TyClifford.com', '
<?php endif; ?>
</td>
<td style="font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-2);">
<?= h(format_count((int)$v['view_count'])) ?>
<?= h(format_count(video_total_views($v))) ?>
<?php if ((int)$v['external_view_count'] > 0): ?>
<span style="color:var(--text-3);"> · <?= h(format_count((int)$v['external_view_count'])) ?> external</span>
<?php endif; ?>
</td>
<td>
<span class="badge <?= $v['published'] ? 'badge-green' : 'badge-gray' ?>">
+24
View File
@@ -0,0 +1,24 @@
<?php
require_once __DIR__ . '/auth.php';
admin_require_auth();
header('Content-Type: application/json; charset=utf-8');
$url = normalize_media_url($_GET['url'] ?? '');
if ($url === '') {
echo json_encode(['ok' => false, 'error' => 'Enter a valid http:// or https:// URL.']);
exit;
}
$metadata = media_external_metadata($url);
$has_info = $metadata['title'] !== ''
|| $metadata['description'] !== ''
|| $metadata['thumbnail_url'] !== ''
|| (int)$metadata['duration'] > 0
|| (int)$metadata['view_count'] > 0;
echo json_encode([
'ok' => $has_info,
'error' => $has_info ? '' : 'No readable media metadata was found for that URL.',
'metadata' => $metadata,
]);