-
+
= $search ? 'No videos match "' . h($search) . '"' : 'No videos yet. Add one! ' ?>
@@ -286,6 +323,9 @@ render_head('Admin — TyClifford.com', '
· = (int)$v['remote_count'] ?> URL= (int)$v['remote_count'] != 1 ? 's' : '' ?>
+
+ = h(format_count((int)$v['view_count'])) ?>
+
= $v['published'] ? 'Published' : 'Draft' ?>
diff --git a/admin/settings.php b/admin/settings.php
index 4c9eb32..d35e903 100644
--- a/admin/settings.php
+++ b/admin/settings.php
@@ -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; }
');
?>
@@ -135,6 +150,10 @@ render_head('Settings — Admin', '
= $total_sources ?>
Sources
+
+ = h(format_count((int)$total_views)) ?>
+ Views
+
= $db_size ?> KB
Database
@@ -177,6 +196,30 @@ render_head('Settings — Admin', '
Default routes are /v/<slug>, /all, and /search/<query>. Pretty URLs require Apache mod_rewrite and the included .htaccess.
+ Public Display
+
+
+ >
+
+ Video view counts
+ Show each video's view count on public cards and the player.
+
+
+
+ >
+
+ Total view count
+ Show the combined public view total.
+
+
+
+ >
+
+ Page statistics
+ Show public library stats on the homepage and catalogue.
+
+
+
Save Settings
diff --git a/catalogue.php b/catalogue.php
index 5880cd2..bee6107 100644
--- a/catalogue.php
+++ b/catalogue.php
@@ -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', '
Library
Video Catalogue
+
- = $paged['total'] ?> = $search ? 'result' : 'video' ?>= $paged['total'] != 1 ? 's' : '' ?>
+ = h(implode(' · ', $header_parts)) ?>
+
+
+
+ Public Videos
+ = h(format_count((int)$public_stats['videos'])) ?>
+
+
+
+ Total Views
+ = h(format_count((int)$public_stats['views'])) ?>
+
+
+
+ Sources
+ = h(format_count((int)$public_stats['sources'])) ?>
+
+
+
+
@@ -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']);
?>
@@ -143,7 +188,7 @@ render_head('Videos — TyClifford.com', '
= h($vdesc) ?>
- = h($created) ?>= $vdur ? ' · ' . format_duration($vdur) : '' ?>
+ = h(implode(' · ', $meta_parts)) ?>
diff --git a/embed.php b/embed.php
index c12919a..de8bc13 100644
--- a/embed.php
+++ b/embed.php
@@ -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.
diff --git a/includes/db.php b/includes/db.php
index 5602f58..4d24f51 100644
--- a/includes/db.php
+++ b/includes/db.php
@@ -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
diff --git a/index.php b/index.php
index 08f17f7..e3499cc 100644
--- a/index.php
+++ b/index.php
@@ -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', '
= h($video['description']) ?>
-
-
- = format_duration((int)$video['duration']) ?>
-
-
+
+
+
= h(implode(' · ', $stream_meta)) ?>
+
@@ -315,6 +340,9 @@ render_head('Media — TyClifford.com', '
+
+
+
Stats
+
Public Library
+
+
+ Videos
+ = h(format_count((int)$public_stats['videos'])) ?>
+
+
+
+ Total Views
+ = h(format_count((int)$public_stats['views'])) ?>
+
+
+
+ Sources
+ = h(format_count((int)$public_stats['sources'])) ?>
+
+
+
+
+