- 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
+5 -3
View File
@@ -64,7 +64,7 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly.
### Adding Videos (Admin)
1. Go to `admin/add.php`
2. Fill in title, description, duration, sort order
2. Fill in title, description, duration, sort order, or paste an external URL and click **Fetch Info** to auto-fill readable metadata
3. Upload a thumbnail (JPG/PNG/WebP)
4. Add one or more video sources. Each source can be either:
- an uploaded local video file, stored under `media/videos/`
@@ -78,6 +78,8 @@ 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.
When adding an external URL, the admin form can fetch title, description, duration, thumbnail metadata, and readable provider view counts through oEmbed, page metadata, and JSON-LD. The save action repeats the metadata lookup as a fallback, so blank titles can be filled from supported external pages even if the browser autofill was not used.
If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server.
### Importing YouTube Channels
@@ -88,7 +90,7 @@ Imported YouTube videos use the video's original publish date for the database `
### View Counts and Public Statistics
Each public player load increments the video's `view_count`, including standalone embeds served through `embed.php`. View counts appear in the admin video list and can be edited per video from **Admin → Edit Video** for older local uploads or manual corrections.
Each public player load increments the video's site-local `view_count`, including standalone embeds served through `embed.php`. External provider totals are stored separately as `external_view_count`, and public totals add local and external counts together. View counts appear in the admin video list and can be edited per video from **Admin → Edit Video** for older local uploads or manual corrections.
The public display is configurable in **Admin → Settings → Public Display**:
@@ -96,7 +98,7 @@ The public display is configurable in **Admin → Settings → Public Display**:
- show or hide the total public view count
- show or hide public page statistics on the homepage and catalogue
The admin dashboard also includes **Upkeeping → Restore external view counts**. It checks existing remote-source videos in batches, scrapes readable provider counts when available, and stores the higher value between the provider count and the local database count. YouTube watch pages are supported first; unsupported providers and local-only uploads can still be corrected manually on the edit screen.
The admin dashboard also includes **Upkeeping → Correct external view counts**. It checks existing remote-source videos in batches, scrapes readable provider counts when available, and adds those provider totals to the media-site totals without double-counting local plays. YouTube has dedicated parsing, and generic metadata patterns cover readable Vimeo, Dailymotion, and similar provider pages when they expose counts. Unsupported providers and local-only uploads can still be corrected manually on the edit screen.
### Public Search
+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,
]);
+1 -1
View File
@@ -167,7 +167,7 @@ render_head('Videos — TyClifford.com', '
$created = substr($v['created_at'], 0, 10);
$meta_parts = [$created];
if ($vdur) $meta_parts[] = format_duration($vdur);
if ($show_video_views) $meta_parts[] = view_count_label((int)$v['view_count']);
if ($show_video_views) $meta_parts[] = view_count_label(video_total_views($v));
?>
<a class="cat-thumb" href="<?= h(public_video_url($vslug)) ?>" title="<?= h($vtitle) ?>">
<div class="cat-thumb-img">
+207 -10
View File
@@ -35,6 +35,7 @@ function get_db(): PDO {
thumbnail TEXT NOT NULL DEFAULT '',
duration INTEGER NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
external_view_count INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
published INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -87,8 +88,12 @@ function ensure_video_schema(PDO $pdo): void {
if (!in_array('view_count', $names, true)) {
$pdo->exec("ALTER TABLE videos ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0");
}
if (!in_array('external_view_count', $names, true)) {
$pdo->exec("ALTER TABLE videos ADD COLUMN external_view_count INTEGER NOT NULL DEFAULT 0");
}
$pdo->exec("UPDATE videos SET view_count=0 WHERE view_count IS NULL");
$pdo->exec("UPDATE videos SET external_view_count=0 WHERE external_view_count IS NULL");
}
function ensure_video_source_schema(PDO $pdo): void {
@@ -132,6 +137,10 @@ function view_count_label(int $views): string {
return format_count($views) . ' view' . ($views === 1 ? '' : 's');
}
function video_total_views(array $video): int {
return max(0, (int)($video['view_count'] ?? 0)) + max(0, (int)($video['external_view_count'] ?? 0));
}
function increment_video_view_count(int $video_id): int {
if ($video_id <= 0) return 0;
@@ -145,7 +154,7 @@ function increment_video_view_count(int $video_id): int {
function total_video_views(bool $published_only = true): int {
$where = $published_only ? 'WHERE published=1' : '';
return max(0, (int)get_db()->query("SELECT COALESCE(SUM(view_count),0) FROM videos $where")->fetchColumn());
return max(0, (int)get_db()->query("SELECT COALESCE(SUM(view_count + external_view_count),0) FROM videos $where")->fetchColumn());
}
function public_page_stats(): array {
@@ -524,7 +533,7 @@ function media_youtube_publish_datetime(string $url): string {
function media_count_from_text(string $value): int {
$value = strtolower(trim(html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
$value = str_replace(["\xc2\xa0", ' views', ' view'], [' ', '', ''], $value);
$value = str_replace(["\xc2\xa0", ' views', ' view', ' plays', ' play'], [' ', '', '', '', ''], $value);
$value = trim($value);
if ($value === '') return 0;
@@ -548,13 +557,35 @@ function media_count_from_text(string $value): int {
return 0;
}
function media_youtube_view_count_from_html(string $html): int {
function media_json_decode_value(string $value): string {
$decoded = json_decode('"' . str_replace(['\\', '"'], ['\\\\', '\"'], $value) . '"');
return is_string($decoded) ? $decoded : $value;
}
function media_pick_longest_text(string $current, string $candidate): string {
$candidate = trim(html_entity_decode(strip_tags($candidate), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return strlen($candidate) > strlen($current) ? $candidate : $current;
}
function media_view_count_from_html(string $html): int {
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$variants = [$html, stripcslashes($html)];
$exact_patterns = [
'/"viewCount"\s*:\s*"(\d+)"/',
'/"viewCount"\s*:\s*(\d+)/',
'/"view_count"\s*:\s*"(\d+)"/',
'/"view_count"\s*:\s*(\d+)/',
'/"views"\s*:\s*"(\d+)"/',
'/"views"\s*:\s*(\d+)/',
'/"plays"\s*:\s*"(\d+)"/',
'/"plays"\s*:\s*(\d+)/',
'/"play_count"\s*:\s*"(\d+)"/',
'/"play_count"\s*:\s*(\d+)/',
'/"stats_number_of_plays"\s*:\s*"(\d+)"/',
'/"stats_number_of_plays"\s*:\s*(\d+)/',
'/"views_total"\s*:\s*"(\d+)"/',
'/"views_total"\s*:\s*(\d+)/',
'/"userInteractionCount"\s*:\s*"(\d+)"/',
'/"userInteractionCount"\s*:\s*(\d+)/',
];
@@ -570,12 +601,12 @@ function media_youtube_view_count_from_html(string $html): int {
'/"viewCountText"\s*:\s*\{\s*"simpleText"\s*:\s*"([^"]+)"/',
'/"shortViewCountText"\s*:\s*\{\s*"simpleText"\s*:\s*"([^"]+)"/',
'/"views"\s*:\s*\{\s*"simpleText"\s*:\s*"([^"]+)"/',
'/([0-9][0-9,.\s]*\s*[KMBkmb]?)\s+views\b/',
'/([0-9][0-9,.\s]*\s*[KMBkmb]?)\s+(?:views|plays)\b/',
];
foreach ($variants as $variant) {
foreach ($text_patterns as $pattern) {
if (preg_match($pattern, $variant, $match)) {
$count = media_count_from_text($match[1]);
$count = media_count_from_text(media_json_decode_value($match[1]));
if ($count > 0) return $count;
}
}
@@ -584,6 +615,10 @@ function media_youtube_view_count_from_html(string $html): int {
return 0;
}
function media_youtube_view_count_from_html(string $html): int {
return media_view_count_from_html($html);
}
function media_youtube_view_count(string $url): int {
$id = media_youtube_id_from_url($url);
if ($id === '') return 0;
@@ -604,7 +639,10 @@ function media_external_view_count_from_url(string $url): int {
return media_youtube_view_count($url);
}
return 0;
$fetch = media_http_get($url, 15, 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8');
if ($fetch['body'] === '' || $fetch['error'] !== '') return 0;
return media_view_count_from_html($fetch['body']);
}
function media_external_view_urls_for_video(int $video_id): array {
@@ -628,7 +666,7 @@ function media_restore_external_view_count_for_video(int $video_id): array {
$urls = media_external_view_urls_for_video($video_id);
if (!$urls) return ['status' => 'no_external', 'current' => 0, 'external' => 0, 'url' => ''];
$stmt = get_db()->prepare("SELECT view_count FROM videos WHERE id=?");
$stmt = get_db()->prepare("SELECT external_view_count FROM videos WHERE id=?");
$stmt->execute([$video_id]);
$current = max(0, (int)$stmt->fetchColumn());
@@ -650,10 +688,10 @@ function media_restore_external_view_count_for_video(int $video_id): array {
return ['status' => 'already_current', 'current' => $current, 'external' => $best, 'url' => $best_url];
}
get_db()->prepare("UPDATE videos SET view_count=?, updated_at=datetime('now') WHERE id=?")
get_db()->prepare("UPDATE videos SET external_view_count=?, updated_at=datetime('now') WHERE id=?")
->execute([$best, $video_id]);
return ['status' => 'restored', 'current' => $current, 'external' => $best, 'url' => $best_url];
return ['status' => 'corrected', 'current' => $current, 'external' => $best, 'url' => $best_url];
}
function media_external_view_count_candidates(int $limit): array {
@@ -683,7 +721,7 @@ function media_external_view_count_candidate_count(): int {
function media_restore_external_view_counts(int $limit = 25): array {
$result = [
'checked' => 0,
'restored' => 0,
'corrected' => 0,
'already_current' => 0,
'not_found' => 0,
'no_external' => 0,
@@ -700,6 +738,10 @@ function media_restore_external_view_counts(int $limit = 25): array {
return $result;
}
function media_correct_external_view_counts(int $limit = 25): array {
return media_restore_external_view_counts($limit);
}
function media_youtube_source_url_for_video(int $video_id): string {
$stmt = get_db()->prepare("
SELECT source_url
@@ -817,6 +859,161 @@ function media_provider_oembed_endpoint(string $url, string $host): string {
return '';
}
function media_iso_duration_to_seconds(string $duration): int {
$duration = trim($duration);
if ($duration === '') return 0;
if (ctype_digit($duration)) return (int)$duration;
if (!preg_match('/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/i', $duration, $m)) return 0;
$days = (int)($m[1] ?? 0);
$hours = (int)($m[2] ?? 0);
$minutes = (int)($m[3] ?? 0);
$seconds = (int)($m[4] ?? 0);
return max(0, ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds);
}
function media_html_meta_content(string $html, array $names): string {
foreach ($names as $name) {
$quoted = preg_quote($name, '/');
$patterns = [
'/<meta[^>]+(?:property|name|itemprop)=["\']' . $quoted . '["\'][^>]+content=["\']([^"\']*)["\']/i',
'/<meta[^>]+content=["\']([^"\']*)["\'][^>]+(?:property|name|itemprop)=["\']' . $quoted . '["\']/i',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $html, $m)) {
$value = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($value !== '') return $value;
}
}
}
return '';
}
function media_html_title_tag(string $html): string {
if (!preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $m)) return '';
return trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
function media_apply_metadata_value(array &$metadata, string $key, $value): void {
if (!is_scalar($value) && !is_array($value)) return;
if (is_array($value) && $key !== 'thumbnail_url') {
$value = $value[0] ?? '';
if (is_array($value)) return;
}
if ($key === 'thumbnail_url') {
if (is_array($value)) $value = $value[0] ?? $value['url'] ?? $value['contentUrl'] ?? '';
if (is_array($value)) $value = $value['url'] ?? $value['contentUrl'] ?? '';
$url = normalize_media_url((string)$value);
if ($url !== '' && $metadata['thumbnail_url'] === '') $metadata['thumbnail_url'] = $url;
return;
}
if ($key === 'duration') {
$seconds = is_numeric($value) ? (int)$value : media_iso_duration_to_seconds((string)$value);
if ($seconds > 0 && (int)$metadata['duration'] <= 0) $metadata['duration'] = $seconds;
return;
}
if ($key === 'view_count') {
$count = is_numeric($value) ? (int)$value : media_count_from_text((string)$value);
if ($count > (int)$metadata['view_count']) $metadata['view_count'] = $count;
return;
}
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($text === '') return;
if ($key === 'description') {
$metadata['description'] = media_pick_longest_text((string)$metadata['description'], $text);
return;
}
if ((string)$metadata[$key] === '') $metadata[$key] = $text;
}
function media_collect_json_metadata($value, array &$metadata): void {
if (!is_array($value)) return;
$name = $value['name'] ?? $value['headline'] ?? $value['title'] ?? '';
if ($name !== '') media_apply_metadata_value($metadata, 'title', $name);
if (!empty($value['description'])) media_apply_metadata_value($metadata, 'description', $value['description']);
if (!empty($value['thumbnailUrl'])) media_apply_metadata_value($metadata, 'thumbnail_url', $value['thumbnailUrl']);
if (!empty($value['thumbnail'])) media_apply_metadata_value($metadata, 'thumbnail_url', $value['thumbnail']);
if (!empty($value['duration'])) media_apply_metadata_value($metadata, 'duration', $value['duration']);
if (!empty($value['uploadDate'])) media_apply_metadata_value($metadata, 'published_at', $value['uploadDate']);
if (!empty($value['datePublished'])) media_apply_metadata_value($metadata, 'published_at', $value['datePublished']);
if (!empty($value['interactionStatistic'])) {
$stats = isset($value['interactionStatistic'][0]) ? $value['interactionStatistic'] : [$value['interactionStatistic']];
foreach ($stats as $stat) {
if (!is_array($stat)) continue;
$type = strtolower((string)($stat['interactionType']['@type'] ?? $stat['interactionType'] ?? ''));
if ($type === '' || str_contains($type, 'watch') || str_contains($type, 'view')) {
media_apply_metadata_value($metadata, 'view_count', $stat['userInteractionCount'] ?? 0);
}
}
}
foreach ($value as $child) {
if (is_array($child)) media_collect_json_metadata($child, $metadata);
}
}
function media_external_metadata(string $url): array {
$url = normalize_media_url($url);
$metadata = [
'url' => $url,
'provider' => '',
'title' => '',
'description' => '',
'thumbnail_url' => '',
'duration' => 0,
'view_count' => 0,
'published_at' => '',
];
if ($url === '') return $metadata;
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
$provider = media_provider_embed($url);
$metadata['provider'] = $provider['provider'] ?? '';
$endpoint = media_provider_oembed_endpoint($url, $host);
if ($endpoint !== '') {
$fetch = media_http_get($endpoint, 8, 'application/json,text/html;q=0.5,*/*;q=0.2');
$data = $fetch['body'] !== '' && $fetch['error'] === '' ? json_decode($fetch['body'], true) : null;
if (is_array($data)) {
media_apply_metadata_value($metadata, 'title', $data['title'] ?? '');
media_apply_metadata_value($metadata, 'description', $data['description'] ?? '');
media_apply_metadata_value($metadata, 'thumbnail_url', $data['thumbnail_url'] ?? '');
if ($metadata['provider'] === '') {
media_apply_metadata_value($metadata, 'provider', strtolower((string)($data['provider_name'] ?? '')));
}
}
}
$fetch = media_http_get($url, 15, 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8');
if ($fetch['body'] === '' || $fetch['error'] !== '') return $metadata;
$html = $fetch['body'];
media_apply_metadata_value($metadata, 'title', media_html_meta_content($html, ['og:title', 'twitter:title', 'title']));
media_apply_metadata_value($metadata, 'title', media_html_title_tag($html));
media_apply_metadata_value($metadata, 'description', media_html_meta_content($html, ['og:description', 'twitter:description', 'description']));
media_apply_metadata_value($metadata, 'thumbnail_url', media_html_meta_content($html, ['og:image', 'twitter:image', 'thumbnailUrl', 'thumbnail']));
media_apply_metadata_value($metadata, 'duration', media_html_meta_content($html, ['video:duration', 'duration']));
media_apply_metadata_value($metadata, 'published_at', media_html_meta_content($html, ['article:published_time', 'datePublished', 'uploadDate']));
media_apply_metadata_value($metadata, 'view_count', media_view_count_from_html($html));
if (preg_match_all('/<script[^>]+type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/is', $html, $matches)) {
foreach ($matches[1] as $json) {
$decoded = json_decode(html_entity_decode($json, ENT_QUOTES | ENT_HTML5, 'UTF-8'), true);
if (is_array($decoded)) media_collect_json_metadata($decoded, $metadata);
}
}
return $metadata;
}
function media_oembed_thumbnail_url(string $url, string $host): string {
$endpoint = media_provider_oembed_endpoint($url, $host);
if ($endpoint === '') return '';
+2 -2
View File
@@ -291,7 +291,7 @@ render_head('Media — TyClifford.com', '
<?php
$stream_meta = [];
if ($video && $video['duration']) $stream_meta[] = format_duration((int)$video['duration']);
if ($video && $show_video_views) $stream_meta[] = view_count_label((int)$video['view_count']);
if ($video && $show_video_views) $stream_meta[] = view_count_label(video_total_views($video));
?>
<?php if ($stream_meta): ?>
<p class="stream-meta"><span><?= h(implode(' · ', $stream_meta)) ?></span></p>
@@ -370,7 +370,7 @@ render_head('Media — TyClifford.com', '
$vdur = (int)$v['duration'];
$created = substr($v['created_at'], 0, 10);
$meta_parts = [$created];
if ($show_video_views) $meta_parts[] = view_count_label((int)$v['view_count']);
if ($show_video_views) $meta_parts[] = view_count_label(video_total_views($v));
?>
<a class="cat-thumb <?= $active ? 'active' : '' ?>"
href="<?= h(public_video_url($vslug)) ?>"