From 2d52255b93fe23bf59345e6b4d5f60cdf1b61eec Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Thu, 25 Jun 2026 04:40:59 -0400 Subject: [PATCH] =?UTF-8?q?-=20What=20changed:=20Added=20external=5Fview?= =?UTF-8?q?=5Fcount=20so=20provider=20totals=20from=20YouTube/Vimeo/etc=20?= =?UTF-8?q?are=20added=20to=20the=20site=E2=80=99s=20total=20without=20dou?= =?UTF-8?q?ble-counting=20local=20plays.=20Updated=20public=20totals=20and?= =?UTF-8?q?=20per-video=20public=20view=20displays=20to=20use=20site=20vie?= =?UTF-8?q?ws=20+=20external=20views.=20Changed=20Upkeeping=20action=20to?= =?UTF-8?q?=20Correct=20external=20view=20counts=20with=20a=20Correct=20Vi?= =?UTF-8?q?ews=20button.=20Expanded=20provider=20scraping=20beyond=20YouTu?= =?UTF-8?q?be=20with=20generic=20page/JSON-LD/oEmbed=20patterns=20for=20re?= =?UTF-8?q?adable=20Vimeo,=20Dailymotion,=20and=20similar=20pages.=20Impro?= =?UTF-8?q?ved=20Add=20Video:Title=20no=20longer=20has=20to=20be=20manuall?= =?UTF-8?q?y=20entered=20first.=20External=20URL=20rows=20now=20have=20Fet?= =?UTF-8?q?ch=20Info.=20Metadata=20can=20fill=20title,=20description,=20du?= =?UTF-8?q?ration,=20source=20label,=20and=20external=20view=20count.=20Sa?= =?UTF-8?q?ve=20also=20retries=20metadata=20extraction=20server-side.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 8 +- admin/add.php | 145 ++++++++++++++++++++++++----- admin/edit.php | 15 ++- admin/index.php | 21 +++-- admin/media_info.php | 24 +++++ catalogue.php | 2 +- includes/db.php | 217 +++++++++++++++++++++++++++++++++++++++++-- index.php | 4 +- 8 files changed, 386 insertions(+), 50 deletions(-) create mode 100644 admin/media_info.php diff --git a/README.md b/README.md index 1c9c083..4255b4b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/admin/add.php b/admin/add.php index 68ce199..24aed78 100644 --- a/admin/add.php +++ b/admin/add.php @@ -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', '

Video Details

- + + value="" placeholder="Auto-filled from external URL when available"> + Leave blank when pasting a supported external URL, then use Fetch Info.
@@ -298,7 +330,11 @@ render_head('Add Video — Admin', '
@@ -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')) { diff --git a/admin/edit.php b/admin/edit.php index 5a1ac7f..ed30c31 100644 --- a/admin/edit.php +++ b/admin/edit.php @@ -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="" min="0">
- + - Use this to restore older local videos or override an imported external count. + Local media-site plays. Use this to restore older local videos manually. +
+
+ + + Provider views from YouTube, Vimeo, and other readable external pages. Upkeeping can correct this automatically.
diff --git a/admin/index.php b/admin/index.php index fd88683..4309753 100644 --- a/admin/index.php +++ b/admin/index.php @@ -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', '
-

Restore external view counts

+

Correct external view counts

- 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 video can be checked. Current database total: .

- +
@@ -324,7 +324,10 @@ render_head('Admin — TyClifford.com', ' - + + 0): ?> + · external + diff --git a/admin/media_info.php b/admin/media_info.php new file mode 100644 index 0000000..80121fa --- /dev/null +++ b/admin/media_info.php @@ -0,0 +1,24 @@ + 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, +]); diff --git a/catalogue.php b/catalogue.php index bee6107..6fa6142 100644 --- a/catalogue.php +++ b/catalogue.php @@ -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)); ?>
diff --git a/includes/db.php b/includes/db.php index 4d24f51..469854b 100644 --- a/includes/db.php +++ b/includes/db.php @@ -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 = [ + '/]+(?:property|name|itemprop)=["\']' . $quoted . '["\'][^>]+content=["\']([^"\']*)["\']/i', + '/]+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>/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('/]+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 ''; diff --git a/index.php b/index.php index e3499cc..0b42083 100644 --- a/index.php +++ b/index.php @@ -291,7 +291,7 @@ render_head('Media — TyClifford.com', '

@@ -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)); ?>