- 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
+239
View File
@@ -34,6 +34,7 @@ function get_db(): PDO {
slug TEXT NOT NULL UNIQUE,
thumbnail TEXT NOT NULL DEFAULT '',
duration INTEGER NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
published INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -58,6 +59,7 @@ function get_db(): PDO {
CREATE INDEX IF NOT EXISTS idx_sources_video ON video_sources(video_id);
");
ensure_video_schema($pdo);
ensure_video_source_schema($pdo);
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_sources_type ON video_sources(source_type)");
@@ -68,6 +70,9 @@ function get_db(): PDO {
'site_title' => 'Ty Clifford',
'site_sub' => 'tyclifford.com / media',
'url_rewrite_mode' => 'pretty',
'public_show_video_views' => '1',
'public_show_total_views' => '1',
'public_show_page_stats' => '1',
];
$ins = $pdo->prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
foreach ($defaults as $k => $v) $ins->execute([$k, $v]);
@@ -75,6 +80,17 @@ function get_db(): PDO {
return $pdo;
}
function ensure_video_schema(PDO $pdo): void {
$cols = $pdo->query("PRAGMA table_info(videos)")->fetchAll();
$names = array_column($cols, 'name');
if (!in_array('view_count', $names, true)) {
$pdo->exec("ALTER TABLE videos ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0");
}
$pdo->exec("UPDATE videos SET view_count=0 WHERE view_count IS NULL");
}
function ensure_video_source_schema(PDO $pdo): void {
$cols = $pdo->query("PRAGMA table_info(video_sources)")->fetchAll();
$names = array_column($cols, 'name');
@@ -101,6 +117,51 @@ function set_setting(string $key, string $value): void {
->execute([$key, $value]);
}
function setting_bool(string $key, bool $fallback = false): bool {
$value = strtolower(trim(setting($key, $fallback ? '1' : '0')));
if ($value === '') return $fallback;
return in_array($value, ['1', 'true', 'yes', 'on'], true);
}
function format_count(int $value): string {
return number_format(max(0, $value));
}
function view_count_label(int $views): string {
$views = max(0, $views);
return format_count($views) . ' view' . ($views === 1 ? '' : 's');
}
function increment_video_view_count(int $video_id): int {
if ($video_id <= 0) return 0;
$db = get_db();
$db->prepare("UPDATE videos SET view_count=view_count+1 WHERE id=?")->execute([$video_id]);
$stmt = $db->prepare("SELECT view_count FROM videos WHERE id=?");
$stmt->execute([$video_id]);
return max(0, (int)$stmt->fetchColumn());
}
function total_video_views(bool $published_only = true): int {
$where = $published_only ? 'WHERE published=1' : '';
return max(0, (int)get_db()->query("SELECT COALESCE(SUM(view_count),0) FROM videos $where")->fetchColumn());
}
function public_page_stats(): array {
$db = get_db();
return [
'videos' => (int)$db->query("SELECT COUNT(*) FROM videos WHERE published=1")->fetchColumn(),
'sources' => (int)$db->query("
SELECT COUNT(*)
FROM video_sources vs
INNER JOIN videos v ON v.id = vs.video_id
WHERE v.published=1
")->fetchColumn(),
'views' => total_video_views(true),
];
}
function url_rewrite_mode(): string {
$mode = strtolower(setting('url_rewrite_mode', 'pretty'));
return in_array($mode, ['pretty', 'query'], true) ? $mode : 'pretty';
@@ -461,6 +522,184 @@ function media_youtube_publish_datetime(string $url): string {
return media_youtube_publish_datetime_from_html($fetch['body']);
}
function media_count_from_text(string $value): int {
$value = strtolower(trim(html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
$value = str_replace(["\xc2\xa0", ' views', ' view'], [' ', '', ''], $value);
$value = trim($value);
if ($value === '') return 0;
if (preg_match('/([0-9]+(?:[,.][0-9]+)*)\s*([kmb])?\b/i', $value, $match)) {
$number = $match[1];
$unit = strtolower($match[2] ?? '');
if ($unit === '') {
return max(0, (int)preg_replace('/\D+/', '', $number));
}
$numeric = (float)str_replace(',', '', $number);
$multiplier = match ($unit) {
'k' => 1000,
'm' => 1000000,
'b' => 1000000000,
default => 1,
};
return max(0, (int)round($numeric * $multiplier));
}
return 0;
}
function media_youtube_view_count_from_html(string $html): int {
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$variants = [$html, stripcslashes($html)];
$exact_patterns = [
'/"viewCount"\s*:\s*"(\d+)"/',
'/"viewCount"\s*:\s*(\d+)/',
'/"userInteractionCount"\s*:\s*"(\d+)"/',
'/"userInteractionCount"\s*:\s*(\d+)/',
];
foreach ($variants as $variant) {
foreach ($exact_patterns as $pattern) {
if (preg_match($pattern, $variant, $match)) {
return max(0, (int)$match[1]);
}
}
}
$text_patterns = [
'/"viewCountText"\s*:\s*\{\s*"simpleText"\s*:\s*"([^"]+)"/',
'/"shortViewCountText"\s*:\s*\{\s*"simpleText"\s*:\s*"([^"]+)"/',
'/"views"\s*:\s*\{\s*"simpleText"\s*:\s*"([^"]+)"/',
'/([0-9][0-9,.\s]*\s*[KMBkmb]?)\s+views\b/',
];
foreach ($variants as $variant) {
foreach ($text_patterns as $pattern) {
if (preg_match($pattern, $variant, $match)) {
$count = media_count_from_text($match[1]);
if ($count > 0) return $count;
}
}
}
return 0;
}
function media_youtube_view_count(string $url): int {
$id = media_youtube_id_from_url($url);
if ($id === '') return 0;
$watch_url = 'https://www.youtube.com/watch?v=' . rawurlencode($id);
$fetch = media_http_get($watch_url, 15, 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8');
if ($fetch['body'] === '' || $fetch['error'] !== '') return 0;
return media_youtube_view_count_from_html($fetch['body']);
}
function media_external_view_count_from_url(string $url): int {
$url = normalize_media_url($url);
if ($url === '') return 0;
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
if (media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube-nocookie.com')) {
return media_youtube_view_count($url);
}
return 0;
}
function media_external_view_urls_for_video(int $video_id): array {
$stmt = get_db()->prepare("
SELECT source_url
FROM video_sources
WHERE video_id=? AND source_type='remote' AND source_url!=''
ORDER BY sort_order ASC, id ASC
");
$stmt->execute([$video_id]);
$urls = [];
foreach ($stmt->fetchAll() as $source) {
$url = normalize_media_url($source['source_url'] ?? '');
if ($url !== '') $urls[] = $url;
}
return $urls;
}
function media_restore_external_view_count_for_video(int $video_id): array {
$urls = media_external_view_urls_for_video($video_id);
if (!$urls) return ['status' => 'no_external', 'current' => 0, 'external' => 0, 'url' => ''];
$stmt = get_db()->prepare("SELECT view_count FROM videos WHERE id=?");
$stmt->execute([$video_id]);
$current = max(0, (int)$stmt->fetchColumn());
$best = 0;
$best_url = '';
foreach ($urls as $url) {
$count = media_external_view_count_from_url($url);
if ($count > $best) {
$best = $count;
$best_url = $url;
}
}
if ($best <= 0) {
return ['status' => 'not_found', 'current' => $current, 'external' => 0, 'url' => ''];
}
if ($best <= $current) {
return ['status' => 'already_current', 'current' => $current, 'external' => $best, 'url' => $best_url];
}
get_db()->prepare("UPDATE videos SET view_count=?, updated_at=datetime('now') WHERE id=?")
->execute([$best, $video_id]);
return ['status' => 'restored', 'current' => $current, 'external' => $best, 'url' => $best_url];
}
function media_external_view_count_candidates(int $limit): array {
$limit = max(1, min(100, $limit));
$stmt = get_db()->prepare("
SELECT DISTINCT v.id
FROM videos v
INNER JOIN video_sources vs ON vs.video_id = v.id
WHERE vs.source_type='remote' AND vs.source_url!=''
ORDER BY v.id DESC
LIMIT :limit
");
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return array_map('intval', array_column($stmt->fetchAll(), 'id'));
}
function media_external_view_count_candidate_count(): int {
return (int)get_db()->query("
SELECT COUNT(DISTINCT v.id)
FROM videos v
INNER JOIN video_sources vs ON vs.video_id = v.id
WHERE vs.source_type='remote' AND vs.source_url!=''
")->fetchColumn();
}
function media_restore_external_view_counts(int $limit = 25): array {
$result = [
'checked' => 0,
'restored' => 0,
'already_current' => 0,
'not_found' => 0,
'no_external' => 0,
];
foreach (media_external_view_count_candidates($limit) as $video_id) {
$result['checked']++;
$applied = media_restore_external_view_count_for_video($video_id);
$status = $applied['status'] ?? 'not_found';
if (!isset($result[$status])) $status = 'not_found';
$result[$status]++;
}
return $result;
}
function media_youtube_source_url_for_video(int $video_id): string {
$stmt = get_db()->prepare("
SELECT source_url