- 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:
@@ -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
@@ -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
@@ -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' ?>
|
||||
|
||||
@@ -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/<slug>, /all, and /search/<query>. 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
@@ -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; ?>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(); ?>
|
||||
|
||||
Reference in New Issue
Block a user