- 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
+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 '';