- 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. 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 ### 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. 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 - Local uploaded sources and remote URL sources
- Provider embeds for popular video and audio platforms - Provider embeds for popular video and audio platforms
- Thumbnail posters - Thumbnail posters
- Per-video view counts and optional public library statistics
- Keyboard accessible - Keyboard accessible
--- ---
+9 -2
View File
@@ -42,6 +42,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$description = trim($_POST['description'] ?? ''); $description = trim($_POST['description'] ?? '');
$duration = (int)($_POST['duration'] ?? 0); $duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 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; $published = isset($_POST['published']) ? 1 : 0;
$auto_thumb = isset($_POST['auto_thumbnail']); $auto_thumb = isset($_POST['auto_thumbnail']);
@@ -74,10 +75,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$error) { if (!$error) {
$db->prepare(" $db->prepare("
UPDATE videos SET title=?, description=?, slug=?, thumbnail=?, UPDATE videos SET title=?, description=?, slug=?, thumbnail=?,
duration=?, sort_order=?, published=?, duration=?, sort_order=?, view_count=?, published=?,
updated_at=datetime('now') updated_at=datetime('now')
WHERE id=? 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 // Update existing sources sort/label/quality
$upd_labels = $_POST['src_label'] ?? []; $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" <input class="form-input" type="number" id="sort_order" name="sort_order"
value="<?= (int)$video['sort_order'] ?>" min="0"> value="<?= (int)$video['sort_order'] ?>" min="0">
</div> </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"> <div class="form-group">
<label class="form-label">Published</label> <label class="form-label">Published</label>
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;margin-top:.35rem;"> <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'; if ($result['not_found']) $msg .= ', ' . $result['not_found'] . ' without a readable original date';
$msg .= '.'; $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 // Search + pagination
@@ -68,7 +78,7 @@ $offset = ($page - 1) * $per_page;
$list_stmt = $db->prepare(" $list_stmt = $db->prepare("
SELECT v.id, v.title, v.slug, v.thumbnail, v.duration, v.published, v.sort_order, 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, COUNT(vs.id) AS source_count,
SUM(CASE WHEN vs.source_type='remote' THEN 1 ELSE 0 END) AS remote_count SUM(CASE WHEN vs.source_type='remote' THEN 1 ELSE 0 END) AS remote_count
FROM videos v FROM videos v
@@ -84,6 +94,8 @@ $list_stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$list_stmt->execute(); $list_stmt->execute();
$videos = $list_stmt->fetchAll(); $videos = $list_stmt->fetchAll();
$youtube_timestamp_candidates = media_youtube_timestamp_candidate_count(); $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])); $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; } .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; } .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; } .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 { 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-copy { max-width:620px; }
.upkeep-form { display:flex; align-items:flex-end; gap:.65rem; flex-wrap:wrap; } .upkeep-form { display:flex; align-items:flex-end; gap:.65rem; flex-wrap:wrap; }
.upkeep-form .form-group { min-width:120px; } .upkeep-form .form-group { min-width:120px; }
@@ -202,6 +216,7 @@ render_head('Admin — TyClifford.com', '
<div class="card"> <div class="card">
<p class="section-heading">Upkeeping</p> <p class="section-heading">Upkeeping</p>
<div class="upkeep-stack">
<div class="upkeep-row"> <div class="upkeep-row">
<div class="upkeep-copy"> <div class="upkeep-copy">
<h2 class="card-title">Correct YouTube timestamps</h2> <h2 class="card-title">Correct YouTube timestamps</h2>
@@ -221,6 +236,27 @@ render_head('Admin — TyClifford.com', '
</button> </button>
</form> </form>
</div> </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>
<div class="admin-toolbar"> <div class="admin-toolbar">
@@ -253,6 +289,7 @@ render_head('Admin — TyClifford.com', '
<th class="title-cell">Title / Slug</th> <th class="title-cell">Title / Slug</th>
<th>Duration</th> <th>Duration</th>
<th>Sources</th> <th>Sources</th>
<th>Views</th>
<th>Status</th> <th>Status</th>
<th>Date</th> <th>Date</th>
<th class="actions-cell">Actions</th> <th class="actions-cell">Actions</th>
@@ -260,7 +297,7 @@ render_head('Admin — TyClifford.com', '
</thead> </thead>
<tbody> <tbody>
<?php if (empty($videos)): ?> <?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>' ?> <?= $search ? 'No videos match "' . h($search) . '"' : 'No videos yet. <a href="add.php" style="color:var(--purple)">Add one!</a>' ?>
</td></tr> </td></tr>
<?php else: ?> <?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> <span style="color:var(--text-3);"> · <?= (int)$v['remote_count'] ?> URL<?= (int)$v['remote_count'] != 1 ? 's' : '' ?></span>
<?php endif; ?> <?php endif; ?>
</td> </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> <td>
<span class="badge <?= $v['published'] ? 'badge-green' : 'badge-gray' ?>"> <span class="badge <?= $v['published'] ? 'badge-green' : 'badge-gray' ?>">
<?= $v['published'] ? 'Published' : 'Draft' ?> <?= $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'); $site_sub = trim($_POST['site_sub'] ?? 'tyclifford.com / media');
$url_mode = $_POST['url_rewrite_mode'] ?? 'pretty'; $url_mode = $_POST['url_rewrite_mode'] ?? 'pretty';
if (!in_array($url_mode, ['pretty', 'query'], true)) $url_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('catalogue_per_page', (string)$per_page);
set_setting('site_title', $site_title); set_setting('site_title', $site_title);
set_setting('site_sub', $site_sub); set_setting('site_sub', $site_sub);
set_setting('url_rewrite_mode', $url_mode); 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.'; $msg = 'Settings saved.';
} }
@@ -44,11 +50,15 @@ $per_page = setting('catalogue_per_page', '10');
$site_title = setting('site_title', 'Ty Clifford'); $site_title = setting('site_title', 'Ty Clifford');
$site_sub = setting('site_sub', 'tyclifford.com / media'); $site_sub = setting('site_sub', 'tyclifford.com / media');
$url_rewrite_mode = url_rewrite_mode(); $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 stats
$db = get_db(); $db = get_db();
$total_videos = $db->query("SELECT COUNT(*) FROM videos")->fetchColumn(); $total_videos = $db->query("SELECT COUNT(*) FROM videos")->fetchColumn();
$total_sources = $db->query("SELECT COUNT(*) FROM video_sources")->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; $db_size = file_exists(DB_PATH) ? round(filesize(DB_PATH)/1024, 1) : 0;
$media_size = 0; $media_size = 0;
if (is_dir(MEDIA_DIR)) { 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-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-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); } .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"> <main class="page" role="main">
@@ -135,6 +150,10 @@ render_head('Settings — Admin', '
<span class="stat-val"><?= $total_sources ?></span> <span class="stat-val"><?= $total_sources ?></span>
<span class="stat-label">Sources</span> <span class="stat-label">Sources</span>
</div> </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"> <div class="stat-tile">
<span class="stat-val"><?= $db_size ?> KB</span> <span class="stat-val"><?= $db_size ?> KB</span>
<span class="stat-label">Database</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> <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>
</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"> <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> <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 Save Settings
+47 -2
View File
@@ -25,6 +25,15 @@ if (url_rewrite_enabled()) {
$per_page = (int)setting('catalogue_per_page', '10'); $per_page = (int)setting('catalogue_per_page', '10');
$page = max(1, (int)($_GET['page'] ?? 1)); $page = max(1, (int)($_GET['page'] ?? 1));
$paged = get_videos_paginated($page, $per_page, true, $search); $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', ' render_head('Videos — TyClifford.com', '
.cat-grid { .cat-grid {
@@ -72,6 +81,14 @@ render_head('Videos — TyClifford.com', '
.public-search { display:flex; gap:.5rem; width:100%; max-width:460px; } .public-search { display:flex; gap:.5rem; width:100%; max-width:460px; }
.public-search input { flex:1; min-width:0; } .public-search input { flex:1; min-width:0; }
@media(max-width:560px) { .public-search { max-width:none; } } @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-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; } .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> <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> <h1 style="font-size:1.1rem;font-weight:700;color:var(--text);">Video Catalogue</h1>
</div> </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"> <span class="cat-count">
<?= $paged['total'] ?> <?= $search ? 'result' : 'video' ?><?= $paged['total'] != 1 ? 's' : '' ?> <?= h(implode(' · ', $header_parts)) ?>
</span> </span>
</div> </div>
<form class="public-search" method="get" action="<?= h(public_catalogue_url()) ?>" role="search"> <form class="public-search" method="get" action="<?= h(public_catalogue_url()) ?>" role="search">
@@ -107,6 +130,25 @@ render_head('Videos — TyClifford.com', '
</form> </form>
</div> </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'])): ?> <?php if (empty($paged['videos'])): ?>
<div class="card"> <div class="card">
<div class="empty-state"> <div class="empty-state">
@@ -123,6 +165,9 @@ render_head('Videos — TyClifford.com', '
$vthumb = $v['thumbnail']; $vthumb = $v['thumbnail'];
$vdur = (int)$v['duration']; $vdur = (int)$v['duration'];
$created = substr($v['created_at'], 0, 10); $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) ?>"> <a class="cat-thumb" href="<?= h(public_video_url($vslug)) ?>" title="<?= h($vtitle) ?>">
<div class="cat-thumb-img"> <div class="cat-thumb-img">
@@ -143,7 +188,7 @@ render_head('Videos — TyClifford.com', '
<?php if ($vdesc): ?> <?php if ($vdesc): ?>
<span class="cat-thumb-desc"><?= h($vdesc) ?></span> <span class="cat-thumb-desc"><?= h($vdesc) ?></span>
<?php endif; ?> <?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> </div>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
+3
View File
@@ -9,6 +9,9 @@ require_once __DIR__ . '/includes/db.php';
$slug = trim($_GET['v'] ?? ''); $slug = trim($_GET['v'] ?? '');
$video = $slug ? get_video_by_slug($slug) : null; $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. // Determine absolute base URL for media files.
// We need absolute URLs here because the embed lives on a foreign domain. // 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, slug TEXT NOT NULL UNIQUE,
thumbnail TEXT NOT NULL DEFAULT '', thumbnail TEXT NOT NULL DEFAULT '',
duration INTEGER NOT NULL DEFAULT 0, duration INTEGER NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
published INTEGER NOT NULL DEFAULT 1, published INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')), 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); CREATE INDEX IF NOT EXISTS idx_sources_video ON video_sources(video_id);
"); ");
ensure_video_schema($pdo);
ensure_video_source_schema($pdo); ensure_video_source_schema($pdo);
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_sources_type ON video_sources(source_type)"); $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_title' => 'Ty Clifford',
'site_sub' => 'tyclifford.com / media', 'site_sub' => 'tyclifford.com / media',
'url_rewrite_mode' => 'pretty', '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 (?, ?)"); $ins = $pdo->prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
foreach ($defaults as $k => $v) $ins->execute([$k, $v]); foreach ($defaults as $k => $v) $ins->execute([$k, $v]);
@@ -75,6 +80,17 @@ function get_db(): PDO {
return $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 { function ensure_video_source_schema(PDO $pdo): void {
$cols = $pdo->query("PRAGMA table_info(video_sources)")->fetchAll(); $cols = $pdo->query("PRAGMA table_info(video_sources)")->fetchAll();
$names = array_column($cols, 'name'); $names = array_column($cols, 'name');
@@ -101,6 +117,51 @@ function set_setting(string $key, string $value): void {
->execute([$key, $value]); ->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 { function url_rewrite_mode(): string {
$mode = strtolower(setting('url_rewrite_mode', 'pretty')); $mode = strtolower(setting('url_rewrite_mode', 'pretty'));
return in_array($mode, ['pretty', 'query'], true) ? $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']); 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 { function media_youtube_source_url_for_video(int $video_id): string {
$stmt = get_db()->prepare(" $stmt = get_db()->prepare("
SELECT source_url SELECT source_url
+58 -5
View File
@@ -20,6 +20,20 @@ if (!$video) {
if ($latest) $video = get_video_by_slug($latest['slug']); 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'); $per_page = (int)setting('catalogue_per_page', '10');
$page = max(1, (int)($_GET['page'] ?? 1)); $page = max(1, (int)($_GET['page'] ?? 1));
$paged = get_videos_paginated($page, $per_page); $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; } } @media(min-width:900px) { .lower-grid { grid-template-columns:2fr 1fr 1fr; } }
.card-about { background:var(--surface-2); } .card-about { background:var(--surface-2); }
.card-social { 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 { list-style:none; display:flex; flex-direction:column; gap:.5rem; }
.social-list a { .social-list a {
display:flex; align-items:center; gap:.65rem; display:flex; align-items:center; gap:.65rem;
@@ -266,11 +288,14 @@ render_head('Media — TyClifford.com', '
<?php if ($video && $video['description']): ?> <?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> <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; ?> <?php endif; ?>
<p class="stream-meta"> <?php
<?php if ($video && $video['duration']): ?> $stream_meta = [];
<span><?= format_duration((int)$video['duration']) ?></span> 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; ?> <?php endif; ?>
</p>
<?php if ($video): ?> <?php if ($video): ?>
<button class="embed-toggle-btn" id="embed-toggle" aria-expanded="false" aria-controls="embed-panel"> <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> <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"> <section class="cat-section">
<div class="cat-header"> <div class="cat-header">
<p class="eyebrow eyebrow-purple">Video Catalogue</p> <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"> <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…"> <input class="form-input" type="search" name="q" placeholder="Search public videos…">
<button class="btn btn-secondary btn-sm" type="submit"> <button class="btn btn-secondary btn-sm" type="submit">
@@ -341,6 +369,8 @@ render_head('Media — TyClifford.com', '
$vthumb = $v['thumbnail']; $vthumb = $v['thumbnail'];
$vdur = (int)$v['duration']; $vdur = (int)$v['duration'];
$created = substr($v['created_at'], 0, 10); $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' : '' ?>" <a class="cat-thumb <?= $active ? 'active' : '' ?>"
href="<?= h(public_video_url($vslug)) ?>" href="<?= h(public_video_url($vslug)) ?>"
@@ -361,7 +391,7 @@ render_head('Media — TyClifford.com', '
</div> </div>
<div class="cat-thumb-body"> <div class="cat-thumb-body">
<span class="cat-thumb-title"><?= h($vtitle) ?></span> <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> </div>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
@@ -412,6 +442,29 @@ render_head('Media — TyClifford.com', '
</ul> </ul>
</div> </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> </div>
<?php render_footer(); ?> <?php render_footer(); ?>