- Implemented:

Added view_count database migration/defaults and shared helpers in 
[includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php).
Public player and embed.php now increment views.
Homepage and catalogue can display per-video views, total views, and 
public stats based on admin settings.
Added Admin → Settings toggles for public view/stat display.
Added Admin → Upkeeping action to restore external view counts from 
readable provider pages, with YouTube scraping support first.
Added manual per-video view-count editing in 
[admin/edit.php](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/edit.php) 
for older local uploads or corrections.
Updated 
[README.md](/Users/tyemeclifford/Documents/GH/mediaplayer/README.md) 
with the new behavior.
This commit is contained in:
Ty Clifford
2026-06-25 04:16:34 -04:00
parent dc71d26c2a
commit 86d1fedcb3
8 changed files with 472 additions and 29 deletions
+13
View File
@@ -86,6 +86,18 @@ Use **Admin → YouTube Importer** to paste a YouTube channel URL, `@handle`, or
Imported YouTube videos use the video's original publish date for the database `created_at` timestamp when YouTube exposes it. The admin dashboard also includes **Upkeeping → Correct YouTube timestamps** to backfill existing YouTube entries in batches.
### 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.
The public display is configurable in **Admin → Settings → Public Display**:
- show or hide per-video view counts
- 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.
### Public Search
The full catalogue page includes a public search for published videos only. Search matches video title, description, and slug; draft videos remain hidden.
@@ -125,6 +137,7 @@ Use the **Sort Order** field — lower numbers appear first. Default is 0.
- Local uploaded sources and remote URL sources
- Provider embeds for popular video and audio platforms
- Thumbnail posters
- Per-video view counts and optional public library statistics
- Keyboard accessible
---
+9 -2
View File
@@ -42,6 +42,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$description = trim($_POST['description'] ?? '');
$duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0);
$view_count = max(0, (int)($_POST['view_count'] ?? $video['view_count']));
$published = isset($_POST['published']) ? 1 : 0;
$auto_thumb = isset($_POST['auto_thumbnail']);
@@ -74,10 +75,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$error) {
$db->prepare("
UPDATE videos SET title=?, description=?, slug=?, thumbnail=?,
duration=?, sort_order=?, published=?,
duration=?, sort_order=?, view_count=?, published=?,
updated_at=datetime('now')
WHERE id=?
")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $published, $id]);
")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $view_count, $published, $id]);
// Update existing sources sort/label/quality
$upd_labels = $_POST['src_label'] ?? [];
@@ -328,6 +329,12 @@ render_head('Edit Video — Admin', '
<input class="form-input" type="number" id="sort_order" name="sort_order"
value="<?= (int)$video['sort_order'] ?>" min="0">
</div>
<div class="form-group">
<label class="form-label" for="view_count">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>
</div>
<div class="form-group">
<label class="form-label">Published</label>
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;margin-top:.35rem;">
+42 -2
View File
@@ -49,6 +49,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($result['not_found']) $msg .= ', ' . $result['not_found'] . ' without a readable original date';
$msg .= '.';
}
if ($action === 'restore_external_view_counts') {
$limit = max(1, min(100, (int)($_POST['external_view_limit'] ?? 25)));
$result = media_restore_external_view_counts($limit);
$msg = 'Upkeeping checked ' . $result['checked'] . ' external video' . ($result['checked'] === 1 ? '' : 's') . ': ';
$msg .= $result['restored'] . ' restored';
if ($result['already_current']) $msg .= ', ' . $result['already_current'] . ' already current';
if ($result['not_found']) $msg .= ', ' . $result['not_found'] . ' without a readable external count';
$msg .= '.';
}
}
// Search + pagination
@@ -68,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.created_at, v.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
@@ -84,6 +94,8 @@ $list_stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$list_stmt->execute();
$videos = $list_stmt->fetchAll();
$youtube_timestamp_candidates = media_youtube_timestamp_candidate_count();
$external_view_candidates = media_external_view_count_candidate_count();
$admin_total_views = total_video_views(false);
$qs = fn($p) => '?' . http_build_query(array_filter(['q' => $search, 'page' => $p]));
@@ -143,7 +155,9 @@ render_head('Admin — TyClifford.com', '
.rec-count { font-family:"Share Tech Mono",monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; }
.section-heading { font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.15em; text-transform:uppercase; color:var(--text-3); margin-bottom:.75rem; }
.hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; line-height:1.5; }
.upkeep-stack { display:flex; flex-direction:column; gap:1rem; }
.upkeep-row { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.upkeep-row + .upkeep-row { border-top:1px solid var(--border); padding-top:1rem; }
.upkeep-copy { max-width:620px; }
.upkeep-form { display:flex; align-items:flex-end; gap:.65rem; flex-wrap:wrap; }
.upkeep-form .form-group { min-width:120px; }
@@ -202,6 +216,7 @@ render_head('Admin — TyClifford.com', '
<div class="card">
<p class="section-heading">Upkeeping</p>
<div class="upkeep-stack">
<div class="upkeep-row">
<div class="upkeep-copy">
<h2 class="card-title">Correct YouTube timestamps</h2>
@@ -221,6 +236,27 @@ render_head('Admin — TyClifford.com', '
</button>
</form>
</div>
<div class="upkeep-row">
<div class="upkeep-copy">
<h2 class="card-title">Restore 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.
<?= $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">
<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
</button>
</form>
</div>
</div>
</div>
<div class="admin-toolbar">
@@ -253,6 +289,7 @@ render_head('Admin — TyClifford.com', '
<th class="title-cell">Title / Slug</th>
<th>Duration</th>
<th>Sources</th>
<th>Views</th>
<th>Status</th>
<th>Date</th>
<th class="actions-cell">Actions</th>
@@ -260,7 +297,7 @@ render_head('Admin — TyClifford.com', '
</thead>
<tbody>
<?php if (empty($videos)): ?>
<tr><td colspan="7" style="text-align:center;padding:3rem;font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
<tr><td colspan="8" style="text-align:center;padding:3rem;font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
<?= $search ? 'No videos match "' . h($search) . '"' : 'No videos yet. <a href="add.php" style="color:var(--purple)">Add one!</a>' ?>
</td></tr>
<?php else: ?>
@@ -286,6 +323,9 @@ render_head('Admin — TyClifford.com', '
<span style="color:var(--text-3);"> · <?= (int)$v['remote_count'] ?> URL<?= (int)$v['remote_count'] != 1 ? 's' : '' ?></span>
<?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'])) ?>
</td>
<td>
<span class="badge <?= $v['published'] ? 'badge-green' : 'badge-gray' ?>">
<?= $v['published'] ? 'Published' : 'Draft' ?>
+43
View File
@@ -14,11 +14,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$site_sub = trim($_POST['site_sub'] ?? 'tyclifford.com / media');
$url_mode = $_POST['url_rewrite_mode'] ?? 'pretty';
if (!in_array($url_mode, ['pretty', 'query'], true)) $url_mode = 'pretty';
$show_video_views = isset($_POST['public_show_video_views']) ? '1' : '0';
$show_total_views = isset($_POST['public_show_total_views']) ? '1' : '0';
$show_page_stats = isset($_POST['public_show_page_stats']) ? '1' : '0';
set_setting('catalogue_per_page', (string)$per_page);
set_setting('site_title', $site_title);
set_setting('site_sub', $site_sub);
set_setting('url_rewrite_mode', $url_mode);
set_setting('public_show_video_views', $show_video_views);
set_setting('public_show_total_views', $show_total_views);
set_setting('public_show_page_stats', $show_page_stats);
$msg = 'Settings saved.';
}
@@ -44,11 +50,15 @@ $per_page = setting('catalogue_per_page', '10');
$site_title = setting('site_title', 'Ty Clifford');
$site_sub = setting('site_sub', 'tyclifford.com / media');
$url_rewrite_mode = url_rewrite_mode();
$public_show_video_views = setting_bool('public_show_video_views', true);
$public_show_total_views = setting_bool('public_show_total_views', true);
$public_show_page_stats = setting_bool('public_show_page_stats', true);
// DB stats
$db = get_db();
$total_videos = $db->query("SELECT COUNT(*) FROM videos")->fetchColumn();
$total_sources = $db->query("SELECT COUNT(*) FROM video_sources")->fetchColumn();
$total_views = total_video_views(false);
$db_size = file_exists(DB_PATH) ? round(filesize(DB_PATH)/1024, 1) : 0;
$media_size = 0;
if (is_dir(MEDIA_DIR)) {
@@ -76,6 +86,11 @@ render_head('Settings — Admin', '
.stat-tile { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r); padding:1rem; display:flex; flex-direction:column; gap:.3rem; }
.stat-val { font-family:"Share Tech Mono",monospace; font-size:1.4rem; color:var(--text); font-weight:700; }
.stat-label{ font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.1em; text-transform:uppercase; color:var(--text-3); }
.option-list { display:grid; grid-template-columns:1fr; gap:.65rem; margin:0 0 1rem; }
@media(min-width:700px) { .option-list { grid-template-columns:repeat(3,1fr); } }
.check-option { display:flex; align-items:flex-start; gap:.55rem; background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r); padding:.75rem; color:var(--text-2); font-size:.78rem; line-height:1.45; }
.check-option input { margin-top:.15rem; accent-color:var(--purple); }
.check-option strong { display:block; color:var(--text); font-size:.8rem; margin-bottom:.1rem; }
');
?>
<main class="page" role="main">
@@ -135,6 +150,10 @@ render_head('Settings — Admin', '
<span class="stat-val"><?= $total_sources ?></span>
<span class="stat-label">Sources</span>
</div>
<div class="stat-tile">
<span class="stat-val"><?= h(format_count((int)$total_views)) ?></span>
<span class="stat-label">Views</span>
</div>
<div class="stat-tile">
<span class="stat-val"><?= $db_size ?> KB</span>
<span class="stat-label">Database</span>
@@ -177,6 +196,30 @@ render_head('Settings — Admin', '
<span class="hint">Default routes are /v/&lt;slug&gt;, /all, and /search/&lt;query&gt;. Pretty URLs require Apache mod_rewrite and the included .htaccess.</span>
</div>
</div>
<p class="form-label" style="margin-bottom:.55rem;">Public Display</p>
<div class="option-list">
<label class="check-option">
<input type="checkbox" name="public_show_video_views" value="1" <?= $public_show_video_views ? 'checked' : '' ?>>
<span>
<strong>Video view counts</strong>
Show each video's view count on public cards and the player.
</span>
</label>
<label class="check-option">
<input type="checkbox" name="public_show_total_views" value="1" <?= $public_show_total_views ? 'checked' : '' ?>>
<span>
<strong>Total view count</strong>
Show the combined public view total.
</span>
</label>
<label class="check-option">
<input type="checkbox" name="public_show_page_stats" value="1" <?= $public_show_page_stats ? 'checked' : '' ?>>
<span>
<strong>Page statistics</strong>
Show public library stats on the homepage and catalogue.
</span>
</label>
</div>
<button class="btn btn-primary btn-sm" type="submit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
Save Settings
+47 -2
View File
@@ -25,6 +25,15 @@ if (url_rewrite_enabled()) {
$per_page = (int)setting('catalogue_per_page', '10');
$page = max(1, (int)($_GET['page'] ?? 1));
$paged = get_videos_paginated($page, $per_page, true, $search);
$show_video_views = setting_bool('public_show_video_views', true);
$show_total_views = setting_bool('public_show_total_views', true);
$show_page_stats = setting_bool('public_show_page_stats', true);
$total_public_views = total_video_views(true);
$public_stats = $show_page_stats ? public_page_stats() : [
'videos' => (int)$paged['total'],
'sources' => 0,
'views' => $total_public_views,
];
render_head('Videos — TyClifford.com', '
.cat-grid {
@@ -72,6 +81,14 @@ render_head('Videos — TyClifford.com', '
.public-search { display:flex; gap:.5rem; width:100%; max-width:460px; }
.public-search input { flex:1; min-width:0; }
@media(max-width:560px) { .public-search { max-width:none; } }
.stats-panel {
background:var(--surface); border:1px solid var(--border);
border-radius:calc(var(--r)+2px); padding:1rem 1.1rem;
display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:.75rem;
}
.stats-item { display:flex; flex-direction:column; gap:.25rem; }
.stats-label { font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.12em; text-transform:uppercase; color:var(--text-3); }
.stats-value { font-family:"Share Tech Mono",monospace; font-size:1rem; color:var(--text); }
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-top:.5rem; }
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
@@ -91,8 +108,14 @@ render_head('Videos — TyClifford.com', '
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">Library</p>
<h1 style="font-size:1.1rem;font-weight:700;color:var(--text);">Video Catalogue</h1>
</div>
<?php
$header_parts = [
$paged['total'] . ' ' . ($search ? 'result' : 'video') . ($paged['total'] != 1 ? 's' : ''),
];
if ($show_total_views) $header_parts[] = view_count_label($total_public_views) . ' total';
?>
<span class="cat-count">
<?= $paged['total'] ?> <?= $search ? 'result' : 'video' ?><?= $paged['total'] != 1 ? 's' : '' ?>
<?= h(implode(' · ', $header_parts)) ?>
</span>
</div>
<form class="public-search" method="get" action="<?= h(public_catalogue_url()) ?>" role="search">
@@ -107,6 +130,25 @@ render_head('Videos — TyClifford.com', '
</form>
</div>
<?php if ($show_page_stats): ?>
<section class="stats-panel" aria-label="Public catalogue statistics">
<div class="stats-item">
<span class="stats-label">Public Videos</span>
<strong class="stats-value"><?= h(format_count((int)$public_stats['videos'])) ?></strong>
</div>
<?php if ($show_total_views): ?>
<div class="stats-item">
<span class="stats-label">Total Views</span>
<strong class="stats-value"><?= h(format_count((int)$public_stats['views'])) ?></strong>
</div>
<?php endif; ?>
<div class="stats-item">
<span class="stats-label">Sources</span>
<strong class="stats-value"><?= h(format_count((int)$public_stats['sources'])) ?></strong>
</div>
</section>
<?php endif; ?>
<?php if (empty($paged['videos'])): ?>
<div class="card">
<div class="empty-state">
@@ -123,6 +165,9 @@ render_head('Videos — TyClifford.com', '
$vthumb = $v['thumbnail'];
$vdur = (int)$v['duration'];
$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']);
?>
<a class="cat-thumb" href="<?= h(public_video_url($vslug)) ?>" title="<?= h($vtitle) ?>">
<div class="cat-thumb-img">
@@ -143,7 +188,7 @@ render_head('Videos — TyClifford.com', '
<?php if ($vdesc): ?>
<span class="cat-thumb-desc"><?= h($vdesc) ?></span>
<?php endif; ?>
<span class="cat-thumb-meta"><?= h($created) ?><?= $vdur ? ' · ' . format_duration($vdur) : '' ?></span>
<span class="cat-thumb-meta"><?= h(implode(' · ', $meta_parts)) ?></span>
</div>
</a>
<?php endforeach; ?>
+3
View File
@@ -9,6 +9,9 @@ require_once __DIR__ . '/includes/db.php';
$slug = trim($_GET['v'] ?? '');
$video = $slug ? get_video_by_slug($slug) : null;
if ($video) {
$video['view_count'] = increment_video_view_count((int)$video['id']);
}
// Determine absolute base URL for media files.
// We need absolute URLs here because the embed lives on a foreign domain.
+239
View File
@@ -34,6 +34,7 @@ function get_db(): PDO {
slug TEXT NOT NULL UNIQUE,
thumbnail TEXT NOT NULL DEFAULT '',
duration INTEGER NOT NULL DEFAULT 0,
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')),
@@ -58,6 +59,7 @@ function get_db(): PDO {
CREATE INDEX IF NOT EXISTS idx_sources_video ON video_sources(video_id);
");
ensure_video_schema($pdo);
ensure_video_source_schema($pdo);
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_sources_type ON video_sources(source_type)");
@@ -68,6 +70,9 @@ function get_db(): PDO {
'site_title' => 'Ty Clifford',
'site_sub' => 'tyclifford.com / media',
'url_rewrite_mode' => 'pretty',
'public_show_video_views' => '1',
'public_show_total_views' => '1',
'public_show_page_stats' => '1',
];
$ins = $pdo->prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
foreach ($defaults as $k => $v) $ins->execute([$k, $v]);
@@ -75,6 +80,17 @@ function get_db(): PDO {
return $pdo;
}
function ensure_video_schema(PDO $pdo): void {
$cols = $pdo->query("PRAGMA table_info(videos)")->fetchAll();
$names = array_column($cols, 'name');
if (!in_array('view_count', $names, true)) {
$pdo->exec("ALTER TABLE videos ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0");
}
$pdo->exec("UPDATE videos SET view_count=0 WHERE view_count IS NULL");
}
function ensure_video_source_schema(PDO $pdo): void {
$cols = $pdo->query("PRAGMA table_info(video_sources)")->fetchAll();
$names = array_column($cols, 'name');
@@ -101,6 +117,51 @@ function set_setting(string $key, string $value): void {
->execute([$key, $value]);
}
function setting_bool(string $key, bool $fallback = false): bool {
$value = strtolower(trim(setting($key, $fallback ? '1' : '0')));
if ($value === '') return $fallback;
return in_array($value, ['1', 'true', 'yes', 'on'], true);
}
function format_count(int $value): string {
return number_format(max(0, $value));
}
function view_count_label(int $views): string {
$views = max(0, $views);
return format_count($views) . ' view' . ($views === 1 ? '' : 's');
}
function increment_video_view_count(int $video_id): int {
if ($video_id <= 0) return 0;
$db = get_db();
$db->prepare("UPDATE videos SET view_count=view_count+1 WHERE id=?")->execute([$video_id]);
$stmt = $db->prepare("SELECT view_count FROM videos WHERE id=?");
$stmt->execute([$video_id]);
return max(0, (int)$stmt->fetchColumn());
}
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());
}
function public_page_stats(): array {
$db = get_db();
return [
'videos' => (int)$db->query("SELECT COUNT(*) FROM videos WHERE published=1")->fetchColumn(),
'sources' => (int)$db->query("
SELECT COUNT(*)
FROM video_sources vs
INNER JOIN videos v ON v.id = vs.video_id
WHERE v.published=1
")->fetchColumn(),
'views' => total_video_views(true),
];
}
function url_rewrite_mode(): string {
$mode = strtolower(setting('url_rewrite_mode', 'pretty'));
return in_array($mode, ['pretty', 'query'], true) ? $mode : 'pretty';
@@ -461,6 +522,184 @@ function media_youtube_publish_datetime(string $url): string {
return media_youtube_publish_datetime_from_html($fetch['body']);
}
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 = trim($value);
if ($value === '') return 0;
if (preg_match('/([0-9]+(?:[,.][0-9]+)*)\s*([kmb])?\b/i', $value, $match)) {
$number = $match[1];
$unit = strtolower($match[2] ?? '');
if ($unit === '') {
return max(0, (int)preg_replace('/\D+/', '', $number));
}
$numeric = (float)str_replace(',', '', $number);
$multiplier = match ($unit) {
'k' => 1000,
'm' => 1000000,
'b' => 1000000000,
default => 1,
};
return max(0, (int)round($numeric * $multiplier));
}
return 0;
}
function media_youtube_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+)/',
'/"userInteractionCount"\s*:\s*"(\d+)"/',
'/"userInteractionCount"\s*:\s*(\d+)/',
];
foreach ($variants as $variant) {
foreach ($exact_patterns as $pattern) {
if (preg_match($pattern, $variant, $match)) {
return max(0, (int)$match[1]);
}
}
}
$text_patterns = [
'/"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/',
];
foreach ($variants as $variant) {
foreach ($text_patterns as $pattern) {
if (preg_match($pattern, $variant, $match)) {
$count = media_count_from_text($match[1]);
if ($count > 0) return $count;
}
}
}
return 0;
}
function media_youtube_view_count(string $url): int {
$id = media_youtube_id_from_url($url);
if ($id === '') return 0;
$watch_url = 'https://www.youtube.com/watch?v=' . rawurlencode($id);
$fetch = media_http_get($watch_url, 15, 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8');
if ($fetch['body'] === '' || $fetch['error'] !== '') return 0;
return media_youtube_view_count_from_html($fetch['body']);
}
function media_external_view_count_from_url(string $url): int {
$url = normalize_media_url($url);
if ($url === '') return 0;
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
if (media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube-nocookie.com')) {
return media_youtube_view_count($url);
}
return 0;
}
function media_external_view_urls_for_video(int $video_id): array {
$stmt = get_db()->prepare("
SELECT source_url
FROM video_sources
WHERE video_id=? AND source_type='remote' AND source_url!=''
ORDER BY sort_order ASC, id ASC
");
$stmt->execute([$video_id]);
$urls = [];
foreach ($stmt->fetchAll() as $source) {
$url = normalize_media_url($source['source_url'] ?? '');
if ($url !== '') $urls[] = $url;
}
return $urls;
}
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->execute([$video_id]);
$current = max(0, (int)$stmt->fetchColumn());
$best = 0;
$best_url = '';
foreach ($urls as $url) {
$count = media_external_view_count_from_url($url);
if ($count > $best) {
$best = $count;
$best_url = $url;
}
}
if ($best <= 0) {
return ['status' => 'not_found', 'current' => $current, 'external' => 0, 'url' => ''];
}
if ($best <= $current) {
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=?")
->execute([$best, $video_id]);
return ['status' => 'restored', 'current' => $current, 'external' => $best, 'url' => $best_url];
}
function media_external_view_count_candidates(int $limit): array {
$limit = max(1, min(100, $limit));
$stmt = get_db()->prepare("
SELECT DISTINCT v.id
FROM videos v
INNER JOIN video_sources vs ON vs.video_id = v.id
WHERE vs.source_type='remote' AND vs.source_url!=''
ORDER BY v.id DESC
LIMIT :limit
");
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return array_map('intval', array_column($stmt->fetchAll(), 'id'));
}
function media_external_view_count_candidate_count(): int {
return (int)get_db()->query("
SELECT COUNT(DISTINCT v.id)
FROM videos v
INNER JOIN video_sources vs ON vs.video_id = v.id
WHERE vs.source_type='remote' AND vs.source_url!=''
")->fetchColumn();
}
function media_restore_external_view_counts(int $limit = 25): array {
$result = [
'checked' => 0,
'restored' => 0,
'already_current' => 0,
'not_found' => 0,
'no_external' => 0,
];
foreach (media_external_view_count_candidates($limit) as $video_id) {
$result['checked']++;
$applied = media_restore_external_view_count_for_video($video_id);
$status = $applied['status'] ?? 'not_found';
if (!isset($result[$status])) $status = 'not_found';
$result[$status]++;
}
return $result;
}
function media_youtube_source_url_for_video(int $video_id): string {
$stmt = get_db()->prepare("
SELECT source_url
+58 -5
View File
@@ -20,6 +20,20 @@ if (!$video) {
if ($latest) $video = get_video_by_slug($latest['slug']);
}
if ($video) {
$video['view_count'] = increment_video_view_count((int)$video['id']);
}
$show_video_views = setting_bool('public_show_video_views', true);
$show_total_views = setting_bool('public_show_total_views', true);
$show_page_stats = setting_bool('public_show_page_stats', true);
$total_public_views = total_video_views(true);
$public_stats = $show_page_stats ? public_page_stats() : [
'videos' => 0,
'sources' => 0,
'views' => $total_public_views,
];
$per_page = (int)setting('catalogue_per_page', '10');
$page = max(1, (int)($_GET['page'] ?? 1));
$paged = get_videos_paginated($page, $per_page);
@@ -143,6 +157,14 @@ render_head('Media — TyClifford.com', '
@media(min-width:900px) { .lower-grid { grid-template-columns:2fr 1fr 1fr; } }
.card-about { background:var(--surface-2); }
.card-social { background:var(--surface-2); }
.stats-list { list-style:none; display:flex; flex-direction:column; gap:.65rem; }
.stats-list li {
display:flex; align-items:center; justify-content:space-between; gap:1rem;
border-bottom:1px solid var(--border-2); padding-bottom:.55rem;
}
.stats-list li:last-child { border-bottom:0; padding-bottom:0; }
.stats-label { font-family:"Share Tech Mono",monospace; font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; color:var(--text-3); }
.stats-value { font-family:"Share Tech Mono",monospace; font-size:.88rem; color:var(--text); }
.social-list { list-style:none; display:flex; flex-direction:column; gap:.5rem; }
.social-list a {
display:flex; align-items:center; gap:.65rem;
@@ -266,11 +288,14 @@ render_head('Media — TyClifford.com', '
<?php if ($video && $video['description']): ?>
<p class="stream-meta" style="font-family:Inter,sans-serif;font-size:.78rem;color:var(--text-2);letter-spacing:0;max-width:60ch;line-height:1.5"><?= h($video['description']) ?></p>
<?php endif; ?>
<p class="stream-meta">
<?php if ($video && $video['duration']): ?>
<span><?= format_duration((int)$video['duration']) ?></span>
<?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']);
?>
<?php if ($stream_meta): ?>
<p class="stream-meta"><span><?= h(implode(' · ', $stream_meta)) ?></span></p>
<?php endif; ?>
</p>
<?php if ($video): ?>
<button class="embed-toggle-btn" id="embed-toggle" aria-expanded="false" aria-controls="embed-panel">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
@@ -315,6 +340,9 @@ render_head('Media — TyClifford.com', '
<section class="cat-section">
<div class="cat-header">
<p class="eyebrow eyebrow-purple">Video Catalogue</p>
<?php if ($show_total_views): ?>
<span class="cat-count"><?= h(view_count_label($total_public_views)) ?> total</span>
<?php endif; ?>
<form class="cat-search" method="get" action="<?= h(public_catalogue_url()) ?>" role="search">
<input class="form-input" type="search" name="q" placeholder="Search public videos…">
<button class="btn btn-secondary btn-sm" type="submit">
@@ -341,6 +369,8 @@ render_head('Media — TyClifford.com', '
$vthumb = $v['thumbnail'];
$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']);
?>
<a class="cat-thumb <?= $active ? 'active' : '' ?>"
href="<?= h(public_video_url($vslug)) ?>"
@@ -361,7 +391,7 @@ render_head('Media — TyClifford.com', '
</div>
<div class="cat-thumb-body">
<span class="cat-thumb-title"><?= h($vtitle) ?></span>
<span class="cat-thumb-meta"><?= h($created) ?></span>
<span class="cat-thumb-meta"><?= h(implode(' · ', $meta_parts)) ?></span>
</div>
</a>
<?php endforeach; ?>
@@ -412,6 +442,29 @@ render_head('Media — TyClifford.com', '
</ul>
</div>
<?php if ($show_page_stats): ?>
<div class="card card-social">
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">Stats</p>
<h2 class="card-title">Public Library</h2>
<ul class="stats-list" aria-label="Public library statistics">
<li>
<span class="stats-label">Videos</span>
<strong class="stats-value"><?= h(format_count((int)$public_stats['videos'])) ?></strong>
</li>
<?php if ($show_total_views): ?>
<li>
<span class="stats-label">Total Views</span>
<strong class="stats-value"><?= h(format_count((int)$public_stats['views'])) ?></strong>
</li>
<?php endif; ?>
<li>
<span class="stats-label">Sources</span>
<strong class="stats-value"><?= h(format_count((int)$public_stats['sources'])) ?></strong>
</li>
</ul>
</div>
<?php endif; ?>
</div>
<?php render_footer(); ?>