Files
mediaplayer/includes/db.php
T
Ty Clifford a3aa51c4e2 - Live homepage:
Added default Live settings in 
[includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php) 
using https://stream.place/embed/tyclifford.com.
Added Admin → Settings → Live Section controls in 
[admin/settings.php](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/settings.php): 
enable/disable, live title, and embed URL/iframe input.
Added a homepage Videos / Live toggle in 
[index.php](/Users/tyemeclifford/Documents/GH/mediaplayer/index.php). 
Videos remains the default view, so the homepage behaves normally until 
Live is selected.
Imgur:
Added Imgur URL normalization, embed support, oEmbed/thumbnail hooks, 
and external view count scraping support in 
[includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php).
Supports imgur.com, albums/galleries, and direct i.imgur.com 
photos/GIFs/videos by routing them through Imgur embeds.
Imgur external views now flow into external_view_count, so per-item and 
overall site totals include readable Imgur provider counts.
Updated the add-video hint and README docs.
2026-06-25 07:38:19 -04:00

1542 lines
60 KiB
PHP

<?php
/**
* db.php — SQLite database bootstrap & helpers
*/
define('DB_PATH', __DIR__ . '/../data/media.db');
define('MEDIA_DIR', __DIR__ . '/../media/');
define('MEDIA_URL', 'media/');
function get_db(): PDO {
static $pdo = null;
if ($pdo) return $pdo;
$dir = dirname(DB_PATH);
if (!is_dir($dir)) mkdir($dir, 0755, true);
$pdo = new PDO('sqlite:' . DB_PATH);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA foreign_keys=ON');
// Schema
$pdo->exec("
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Untitled',
description TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL UNIQUE,
thumbnail TEXT NOT NULL DEFAULT '',
duration INTEGER NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
external_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')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS video_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
source_type TEXT NOT NULL DEFAULT 'local',
file_path TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT 'video/mp4',
quality TEXT NOT NULL DEFAULT '720p',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_videos_published ON videos(published);
CREATE INDEX IF NOT EXISTS idx_videos_sort ON videos(sort_order, id);
CREATE INDEX IF NOT EXISTS idx_videos_title ON videos(title);
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)");
// Default settings
$defaults = [
'catalogue_per_page' => '10',
'admin_password' => password_hash('admin', PASSWORD_DEFAULT),
'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',
'live_enabled' => '1',
'live_title' => 'Ty Clifford live stream',
'live_embed' => 'https://stream.place/embed/tyclifford.com',
];
$ins = $pdo->prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
foreach ($defaults as $k => $v) $ins->execute([$k, $v]);
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");
}
if (!in_array('external_view_count', $names, true)) {
$pdo->exec("ALTER TABLE videos ADD COLUMN external_view_count INTEGER NOT NULL DEFAULT 0");
}
$pdo->exec("UPDATE videos SET view_count=0 WHERE view_count IS NULL");
$pdo->exec("UPDATE videos SET external_view_count=0 WHERE external_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');
if (!in_array('source_type', $names, true)) {
$pdo->exec("ALTER TABLE video_sources ADD COLUMN source_type TEXT NOT NULL DEFAULT 'local'");
}
if (!in_array('source_url', $names, true)) {
$pdo->exec("ALTER TABLE video_sources ADD COLUMN source_url TEXT NOT NULL DEFAULT ''");
}
$pdo->exec("UPDATE video_sources SET source_type='local' WHERE source_type='' OR source_type IS NULL");
}
function setting(string $key, string $fallback = ''): string {
$row = get_db()->prepare("SELECT value FROM settings WHERE key=?");
$row->execute([$key]);
$r = $row->fetch();
return $r ? $r['value'] : $fallback;
}
function set_setting(string $key, string $value): void {
get_db()->prepare("INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)")
->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 video_total_views(array $video): int {
return max(0, (int)($video['view_count'] ?? 0)) + max(0, (int)($video['external_view_count'] ?? 0));
}
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 + external_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 media_iframe_src_from_input(string $value): string {
$value = trim($value);
if ($value === '') return '';
if (stripos($value, '<iframe') !== false && preg_match('/\ssrc=["\']([^"\']+)["\']/i', $value, $match)) {
$value = html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
return normalize_media_url($value);
}
function live_embed_url(): string {
return media_iframe_src_from_input(setting('live_embed', 'https://stream.place/embed/tyclifford.com'));
}
function live_embed_title(): string {
$title = trim(setting('live_title', 'Ty Clifford live stream'));
return $title !== '' ? $title : 'Live stream';
}
function live_enabled(): bool {
return setting_bool('live_enabled', true) && live_embed_url() !== '';
}
function url_rewrite_mode(): string {
$mode = strtolower(setting('url_rewrite_mode', 'pretty'));
return in_array($mode, ['pretty', 'query'], true) ? $mode : 'pretty';
}
function url_rewrite_enabled(): bool {
return url_rewrite_mode() === 'pretty';
}
function public_base_path(): string {
$script = str_replace('\\', '/', $_SERVER['SCRIPT_NAME'] ?? '');
$dir = rtrim(dirname($script), '/');
if ($dir === '/' || $dir === '.') $dir = '';
if (str_ends_with($dir, '/admin')) $dir = substr($dir, 0, -6);
return $dir;
}
function public_url_path(string $path): string {
$base = public_base_path();
$path = '/' . ltrim($path, '/');
if ($path === '/') return $base === '' ? '/' : $base . '/';
return $base . $path;
}
function public_media_url(string $path = ''): string {
$media_url = public_url_path('/media/');
$path = ltrim($path, '/');
return $path === '' ? $media_url : rtrim($media_url, '/') . '/' . $path;
}
function public_home_url(): string {
return url_rewrite_enabled() ? public_url_path('/') : public_url_path('/index.php');
}
function public_catalogue_url(): string {
return url_rewrite_enabled() ? public_url_path('/all') : public_url_path('/catalogue.php');
}
function public_video_url(string $slug): string {
$slug = trim($slug);
if (url_rewrite_enabled()) return public_url_path('/v/' . rawurlencode($slug));
return public_url_path('/index.php') . '?v=' . rawurlencode($slug);
}
function public_search_url(string $query): string {
$query = trim($query);
if ($query === '') return public_catalogue_url();
if (url_rewrite_enabled()) return public_url_path('/search/' . rawurlencode($query));
return public_url_path('/catalogue.php') . '?q=' . rawurlencode($query);
}
function public_page_url_pattern(string $base_url): string {
return $base_url . (strpos($base_url, '?') === false ? '?' : '&') . 'page=%d';
}
function public_catalogue_page_url_pattern(string $search = ''): string {
return public_page_url_pattern($search !== '' ? public_search_url($search) : public_catalogue_url());
}
function public_player_page_url_pattern(string $slug = ''): string {
$base = $slug !== '' ? public_video_url($slug) : public_home_url();
return public_page_url_pattern($base);
}
function public_request_path(): string {
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
return is_string($path) && $path !== '' ? $path : '/';
}
function public_route_segments(): array {
$path = public_request_path();
$base = public_base_path();
if ($base !== '' && ($path === $base || str_starts_with($path, $base . '/'))) {
$path = substr($path, strlen($base));
}
$segments = array_values(array_filter(explode('/', trim($path, '/')), 'strlen'));
return array_map('rawurldecode', $segments);
}
function public_video_slug_from_request(): string {
$slug = trim((string)($_GET['v'] ?? ''));
if ($slug !== '') return $slug;
$segments = public_route_segments();
return (($segments[0] ?? '') === 'v' && isset($segments[1])) ? trim($segments[1]) : '';
}
function public_search_query_from_request(): string {
if (isset($_GET['q'])) return trim((string)$_GET['q']);
$segments = public_route_segments();
return (($segments[0] ?? '') === 'search' && isset($segments[1])) ? trim($segments[1]) : '';
}
function public_request_scheme(): string {
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
}
function public_origin(): string {
return public_request_scheme() . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
}
function public_absolute_url(string $url): string {
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $url)) return $url;
if (str_starts_with($url, '//')) return public_request_scheme() . ':' . $url;
if (str_starts_with($url, '/')) return public_origin() . $url;
return public_origin() . public_url_path($url);
}
function make_slug(string $title, int $id = 0): string {
$slug = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $title), '-'));
if ($id) {
$exists = get_db()->prepare("SELECT id FROM videos WHERE slug=? AND id!=?");
$exists->execute([$slug, $id]);
} else {
$exists = get_db()->prepare("SELECT id FROM videos WHERE slug=?");
$exists->execute([$slug]);
}
if ($exists->fetch()) $slug .= '-' . ($id ?: time());
return $slug ?: 'video-' . time();
}
function mime_from_ext(string $path): string {
$url_path = parse_url($path, PHP_URL_PATH);
if (is_string($url_path) && $url_path !== '') {
$path = $url_path;
}
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return match($ext) {
'mp4' => 'video/mp4',
'm4v' => 'video/mp4',
'webm' => 'video/webm',
'ogv' => 'video/ogg',
'ogg' => 'video/ogg',
'mov' => 'video/quicktime',
'mkv' => 'video/x-matroska',
'avi' => 'video/x-msvideo',
'm3u8' => 'application/vnd.apple.mpegurl',
'mpd' => 'application/dash+xml',
'mp3' => 'audio/mpeg',
'm4a' => 'audio/mp4',
'aac' => 'audio/aac',
'wav' => 'audio/wav',
'flac' => 'audio/flac',
default => 'video/mp4',
};
}
function normalize_media_url(string $url): string {
$url = trim($url);
if ($url === '') return '';
if (str_starts_with($url, '//')) $url = 'https:' . $url;
if (!filter_var($url, FILTER_VALIDATE_URL)) return '';
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true) ? $url : '';
}
function media_host_matches(string $host, string $domain): bool {
$host = strtolower($host);
$domain = strtolower($domain);
return $host === $domain || substr($host, -strlen('.' . $domain)) === '.' . $domain;
}
function media_scalar_string($value): string {
return is_array($value) ? '' : trim((string)$value);
}
function media_query_values(string $url): array {
$query = parse_url($url, PHP_URL_QUERY);
if (!is_string($query) || $query === '') return [];
$values = [];
parse_str($query, $values);
return is_array($values) ? $values : [];
}
function media_path_segments(string $url): array {
$path = parse_url($url, PHP_URL_PATH);
if (!is_string($path)) return [];
$parts = array_values(array_filter(explode('/', trim($path, '/')), 'strlen'));
return array_map('rawurldecode', $parts);
}
function media_extension_from_url(string $url): string {
$path = parse_url($url, PHP_URL_PATH);
return is_string($path) ? strtolower(pathinfo($path, PATHINFO_EXTENSION)) : '';
}
function media_guess_mime(string $url): string {
$map = [
'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm',
'ogv' => 'video/ogg', 'ogg' => 'video/ogg', 'mov' => 'video/quicktime',
'mkv' => 'video/x-matroska', 'avi' => 'video/x-msvideo',
'm3u8' => 'application/vnd.apple.mpegurl', 'mpd' => 'application/dash+xml',
'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'aac' => 'audio/aac',
'wav' => 'audio/wav', 'flac' => 'audio/flac',
];
return $map[media_extension_from_url($url)] ?? '';
}
function media_append_query(string $base, array $params): string {
$query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
if ($query === '') return $base;
return $base . (strpos($base, '?') === false ? '?' : '&') . $query;
}
function media_http_get(string $url, int $timeout = 12, string $accept = ''): array {
$url = normalize_media_url($url);
if ($url === '') return ['body' => '', 'code' => 0, 'content_type' => '', 'error' => 'Invalid URL.'];
if ($accept === '') {
$accept = 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.9,application/json;q=0.8,text/html;q=0.7,*/*;q=0.5';
}
$headers = [
'User-Agent: Mozilla/5.0 (compatible; TyCliffordMedia/1.0)',
'Accept: ' . $accept,
'Accept-Language: en-US,en;q=0.9',
];
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_HEADER => true,
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$header_size = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$content_type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if (!is_string($raw) || $raw === '') {
return ['body' => '', 'code' => $code, 'content_type' => $content_type, 'error' => $err ?: 'Empty response.'];
}
return [
'body' => substr($raw, $header_size),
'code' => $code,
'content_type' => $content_type,
'error' => $code >= 400 ? 'HTTP ' . $code : '',
];
}
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => implode("\r\n", $headers),
'timeout' => $timeout,
'ignore_errors' => true,
],
]);
$body = @file_get_contents($url, false, $context);
$response_headers = $http_response_header ?? [];
$code = 0;
$content_type = '';
foreach ($response_headers as $header) {
if (preg_match('#^HTTP/\S+\s+(\d+)#', $header, $m)) $code = (int)$m[1];
if (stripos($header, 'Content-Type:') === 0) $content_type = trim(substr($header, 13));
}
return [
'body' => is_string($body) ? $body : '',
'code' => $code,
'content_type' => $content_type,
'error' => $code >= 400 ? 'HTTP ' . $code : (!is_string($body) ? 'Unable to fetch URL.' : ''),
];
}
function media_parse_youtube_time(string $value): int {
$value = trim($value);
if ($value === '') return 0;
if (ctype_digit($value)) return max(0, (int)$value);
$seconds = 0;
if (preg_match_all('/(\d+)\s*([hms])/i', $value, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$amount = (int)$match[1];
$unit = strtolower($match[2]);
$seconds += $unit === 'h' ? $amount * 3600 : ($unit === 'm' ? $amount * 60 : $amount);
}
}
return max(0, $seconds);
}
function media_clean_token(string $value, string $pattern = '/[^A-Za-z0-9_-]/'): string {
$clean = preg_replace($pattern, '', trim($value));
return is_string($clean) ? $clean : '';
}
function media_twitch_parent_host(): string {
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
$host = strtolower(trim((string)$host));
$host = preg_replace('/:\d+$/', '', $host);
return is_string($host) && $host !== '' ? $host : 'localhost';
}
function media_youtube_id_from_url(string $url): string {
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
$path = (string)(parse_url($url, PHP_URL_PATH) ?? '');
$segments = media_path_segments($url);
$query = media_query_values($url);
$id = '';
if (media_host_matches($host, 'youtu.be') && isset($segments[0])) $id = $segments[0];
if ($id === '' && isset($query['v'])) $id = media_scalar_string($query['v']);
if ($id === '' && preg_match('#/(?:embed|shorts|live|v)/([^/?#]+)#', $path, $m)) $id = $m[1];
return media_clean_token($id);
}
function media_imgur_path_from_url(string $url): string {
$url = normalize_media_url($url);
if ($url === '') return '';
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
$segments = media_path_segments($url);
if (!$segments) return '';
if (media_host_matches($host, 'i.imgur.com')) {
$id = pathinfo($segments[0], PATHINFO_FILENAME);
$id = media_clean_token($id);
return $id !== '' ? $id : '';
}
if (!media_host_matches($host, 'imgur.com')) return '';
if (in_array($segments[0], ['a', 'gallery'], true) && isset($segments[1])) {
$id = media_clean_token($segments[1]);
return $id !== '' ? $segments[0] . '/' . $id : '';
}
$id = media_clean_token($segments[0]);
return $id !== '' ? $id : '';
}
function media_imgur_page_url(string $url): string {
$path = media_imgur_path_from_url($url);
return $path !== '' ? 'https://imgur.com/' . $path : '';
}
function media_imgur_embed_url(string $url): string {
$page = media_imgur_page_url($url);
return $page !== '' ? rtrim($page, '/') . '/embed' : '';
}
function media_normalize_datetime(string $value): string {
$value = trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($value === '') return '';
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $m)) {
return $m[1] . ' 00:00:00';
}
try {
$date = new DateTimeImmutable($value);
return $date->format('Y-m-d H:i:s');
} catch (Throwable $e) {
return '';
}
}
function media_youtube_publish_datetime_from_html(string $html): string {
$patterns = [
'/<meta[^>]+itemprop=["\']datePublished["\'][^>]+content=["\']([^"\']+)["\']/i',
'/<meta[^>]+content=["\']([^"\']+)["\'][^>]+itemprop=["\']datePublished["\']/i',
'/"datePublished"\s*:\s*"([^"]+)"/',
'/"publishDate"\s*:\s*"([^"]+)"/',
'/"uploadDate"\s*:\s*"([^"]+)"/',
];
foreach ($patterns as $pattern) {
if (!preg_match($pattern, $html, $match)) continue;
$value = json_decode('"' . str_replace('"', '\"', $match[1]) . '"');
$date = media_normalize_datetime(is_string($value) ? $value : $match[1]);
if ($date !== '') return $date;
}
return '';
}
function media_youtube_publish_datetime(string $url): string {
$id = media_youtube_id_from_url($url);
if ($id === '') return '';
$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 '';
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', ' plays', ' play'], [' ', '', '', '', ''], $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_json_decode_value(string $value): string {
$decoded = json_decode('"' . str_replace(['\\', '"'], ['\\\\', '\"'], $value) . '"');
return is_string($decoded) ? $decoded : $value;
}
function media_pick_longest_text(string $current, string $candidate): string {
$candidate = trim(html_entity_decode(strip_tags($candidate), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return strlen($candidate) > strlen($current) ? $candidate : $current;
}
function media_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+)/',
'/"view_count"\s*:\s*"(\d+)"/',
'/"view_count"\s*:\s*(\d+)/',
'/"views"\s*:\s*"(\d+)"/',
'/"views"\s*:\s*(\d+)/',
'/"plays"\s*:\s*"(\d+)"/',
'/"plays"\s*:\s*(\d+)/',
'/"play_count"\s*:\s*"(\d+)"/',
'/"play_count"\s*:\s*(\d+)/',
'/"stats_number_of_plays"\s*:\s*"(\d+)"/',
'/"stats_number_of_plays"\s*:\s*(\d+)/',
'/"views_total"\s*:\s*"(\d+)"/',
'/"views_total"\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|plays)\b/',
];
foreach ($variants as $variant) {
foreach ($text_patterns as $pattern) {
if (preg_match($pattern, $variant, $match)) {
$count = media_count_from_text(media_json_decode_value($match[1]));
if ($count > 0) return $count;
}
}
}
return 0;
}
function media_youtube_view_count_from_html(string $html): int {
return media_view_count_from_html($html);
}
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);
}
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$imgur_page = media_imgur_page_url($url);
if ($imgur_page !== '') $url = $imgur_page;
}
$fetch = media_http_get($url, 15, 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8');
if ($fetch['body'] === '' || $fetch['error'] !== '') return 0;
return media_view_count_from_html($fetch['body']);
}
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 external_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 external_view_count=?, updated_at=datetime('now') WHERE id=?")
->execute([$best, $video_id]);
return ['status' => 'corrected', '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,
'corrected' => 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_correct_external_view_counts(int $limit = 25): array {
return media_restore_external_view_counts($limit);
}
function media_youtube_source_url_for_video(int $video_id): string {
$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]);
foreach ($stmt->fetchAll() as $source) {
$url = normalize_media_url($source['source_url'] ?? '');
if ($url !== '' && media_youtube_id_from_url($url) !== '') return $url;
}
return '';
}
function media_apply_youtube_timestamp(int $video_id): array {
$url = media_youtube_source_url_for_video($video_id);
if ($url === '') return ['status' => 'no_youtube', 'date' => '', 'url' => ''];
$published_at = media_youtube_publish_datetime($url);
if ($published_at === '') return ['status' => 'not_found', 'date' => '', 'url' => $url];
$stmt = get_db()->prepare("SELECT created_at FROM videos WHERE id=?");
$stmt->execute([$video_id]);
$current = (string)$stmt->fetchColumn();
if (substr($current, 0, 10) === substr($published_at, 0, 10)) {
return ['status' => 'already_correct', 'date' => $published_at, 'url' => $url];
}
get_db()->prepare("UPDATE videos SET created_at=?, updated_at=datetime('now') WHERE id=?")
->execute([$published_at, $video_id]);
return ['status' => 'corrected', 'date' => $published_at, 'url' => $url];
}
function media_youtube_timestamp_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 LIKE '%youtube.com%'
OR vs.source_url LIKE '%youtu.be%'
OR vs.source_url LIKE '%youtube-nocookie.com%'
)
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_youtube_timestamp_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 LIKE '%youtube.com%'
OR vs.source_url LIKE '%youtu.be%'
OR vs.source_url LIKE '%youtube-nocookie.com%'
)
")->fetchColumn();
}
function media_correct_youtube_timestamps(int $limit = 25): array {
$result = [
'checked' => 0,
'corrected' => 0,
'already_correct' => 0,
'not_found' => 0,
'no_youtube' => 0,
];
foreach (media_youtube_timestamp_candidates($limit) as $video_id) {
$result['checked']++;
$applied = media_apply_youtube_timestamp($video_id);
$status = $applied['status'] ?? 'not_found';
if (!isset($result[$status])) $status = 'not_found';
$result[$status]++;
}
return $result;
}
function media_provider_oembed_endpoint(string $url, string $host): string {
if (media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube-nocookie.com')) {
return 'https://www.youtube.com/oembed?format=json&url=' . rawurlencode($url);
}
if (media_host_matches($host, 'vimeo.com')) {
return 'https://vimeo.com/api/oembed.json?url=' . rawurlencode($url);
}
if (media_host_matches($host, 'dailymotion.com') || media_host_matches($host, 'dai.ly')) {
return 'https://www.dailymotion.com/services/oembed?url=' . rawurlencode($url);
}
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$imgur_page = media_imgur_page_url($url);
if ($imgur_page !== '') return 'https://api.imgur.com/oembed.json?url=' . rawurlencode($imgur_page);
}
if (media_host_matches($host, 'tiktok.com')) {
return 'https://www.tiktok.com/oembed?url=' . rawurlencode($url);
}
if (media_host_matches($host, 'soundcloud.com')) {
return 'https://soundcloud.com/oembed?format=json&url=' . rawurlencode($url);
}
if (media_host_matches($host, 'open.spotify.com')) {
return 'https://open.spotify.com/oembed?url=' . rawurlencode($url);
}
if (media_host_matches($host, 'wistia.com') || media_host_matches($host, 'wi.st')) {
return 'https://fast.wistia.com/oembed?url=' . rawurlencode($url);
}
return '';
}
function media_iso_duration_to_seconds(string $duration): int {
$duration = trim($duration);
if ($duration === '') return 0;
if (ctype_digit($duration)) return (int)$duration;
if (!preg_match('/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/i', $duration, $m)) return 0;
$days = (int)($m[1] ?? 0);
$hours = (int)($m[2] ?? 0);
$minutes = (int)($m[3] ?? 0);
$seconds = (int)($m[4] ?? 0);
return max(0, ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds);
}
function media_html_meta_content(string $html, array $names): string {
foreach ($names as $name) {
$quoted = preg_quote($name, '/');
$patterns = [
'/<meta[^>]+(?:property|name|itemprop)=["\']' . $quoted . '["\'][^>]+content=["\']([^"\']*)["\']/i',
'/<meta[^>]+content=["\']([^"\']*)["\'][^>]+(?:property|name|itemprop)=["\']' . $quoted . '["\']/i',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $html, $m)) {
$value = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($value !== '') return $value;
}
}
}
return '';
}
function media_html_title_tag(string $html): string {
if (!preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $m)) return '';
return trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
function media_apply_metadata_value(array &$metadata, string $key, $value): void {
if (!is_scalar($value) && !is_array($value)) return;
if (is_array($value) && $key !== 'thumbnail_url') {
$value = $value[0] ?? '';
if (is_array($value)) return;
}
if ($key === 'thumbnail_url') {
if (is_array($value)) $value = $value[0] ?? $value['url'] ?? $value['contentUrl'] ?? '';
if (is_array($value)) $value = $value['url'] ?? $value['contentUrl'] ?? '';
$url = normalize_media_url((string)$value);
if ($url !== '' && $metadata['thumbnail_url'] === '') $metadata['thumbnail_url'] = $url;
return;
}
if ($key === 'duration') {
$seconds = is_numeric($value) ? (int)$value : media_iso_duration_to_seconds((string)$value);
if ($seconds > 0 && (int)$metadata['duration'] <= 0) $metadata['duration'] = $seconds;
return;
}
if ($key === 'view_count') {
$count = is_numeric($value) ? (int)$value : media_count_from_text((string)$value);
if ($count > (int)$metadata['view_count']) $metadata['view_count'] = $count;
return;
}
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($text === '') return;
if ($key === 'description') {
$metadata['description'] = media_pick_longest_text((string)$metadata['description'], $text);
return;
}
if ((string)$metadata[$key] === '') $metadata[$key] = $text;
}
function media_collect_json_metadata($value, array &$metadata): void {
if (!is_array($value)) return;
$name = $value['name'] ?? $value['headline'] ?? $value['title'] ?? '';
if ($name !== '') media_apply_metadata_value($metadata, 'title', $name);
if (!empty($value['description'])) media_apply_metadata_value($metadata, 'description', $value['description']);
if (!empty($value['thumbnailUrl'])) media_apply_metadata_value($metadata, 'thumbnail_url', $value['thumbnailUrl']);
if (!empty($value['thumbnail'])) media_apply_metadata_value($metadata, 'thumbnail_url', $value['thumbnail']);
if (!empty($value['duration'])) media_apply_metadata_value($metadata, 'duration', $value['duration']);
if (!empty($value['uploadDate'])) media_apply_metadata_value($metadata, 'published_at', $value['uploadDate']);
if (!empty($value['datePublished'])) media_apply_metadata_value($metadata, 'published_at', $value['datePublished']);
if (!empty($value['interactionStatistic'])) {
$stats = isset($value['interactionStatistic'][0]) ? $value['interactionStatistic'] : [$value['interactionStatistic']];
foreach ($stats as $stat) {
if (!is_array($stat)) continue;
$type = strtolower((string)($stat['interactionType']['@type'] ?? $stat['interactionType'] ?? ''));
if ($type === '' || str_contains($type, 'watch') || str_contains($type, 'view')) {
media_apply_metadata_value($metadata, 'view_count', $stat['userInteractionCount'] ?? 0);
}
}
}
foreach ($value as $child) {
if (is_array($child)) media_collect_json_metadata($child, $metadata);
}
}
function media_external_metadata(string $url): array {
$url = normalize_media_url($url);
$metadata = [
'url' => $url,
'provider' => '',
'title' => '',
'description' => '',
'thumbnail_url' => '',
'duration' => 0,
'view_count' => 0,
'published_at' => '',
];
if ($url === '') return $metadata;
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$imgur_page = media_imgur_page_url($url);
if ($imgur_page !== '') {
$url = $imgur_page;
$metadata['url'] = $url;
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
}
}
$provider = media_provider_embed($url);
$metadata['provider'] = $provider['provider'] ?? '';
$endpoint = media_provider_oembed_endpoint($url, $host);
if ($endpoint !== '') {
$fetch = media_http_get($endpoint, 8, 'application/json,text/html;q=0.5,*/*;q=0.2');
$data = $fetch['body'] !== '' && $fetch['error'] === '' ? json_decode($fetch['body'], true) : null;
if (is_array($data)) {
media_apply_metadata_value($metadata, 'title', $data['title'] ?? '');
media_apply_metadata_value($metadata, 'description', $data['description'] ?? '');
media_apply_metadata_value($metadata, 'thumbnail_url', $data['thumbnail_url'] ?? '');
if ($metadata['provider'] === '') {
media_apply_metadata_value($metadata, 'provider', strtolower((string)($data['provider_name'] ?? '')));
}
}
}
$fetch = media_http_get($url, 15, 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8');
if ($fetch['body'] === '' || $fetch['error'] !== '') return $metadata;
$html = $fetch['body'];
media_apply_metadata_value($metadata, 'title', media_html_meta_content($html, ['og:title', 'twitter:title', 'title']));
media_apply_metadata_value($metadata, 'title', media_html_title_tag($html));
media_apply_metadata_value($metadata, 'description', media_html_meta_content($html, ['og:description', 'twitter:description', 'description']));
media_apply_metadata_value($metadata, 'thumbnail_url', media_html_meta_content($html, ['og:image', 'twitter:image', 'thumbnailUrl', 'thumbnail']));
media_apply_metadata_value($metadata, 'duration', media_html_meta_content($html, ['video:duration', 'duration']));
media_apply_metadata_value($metadata, 'published_at', media_html_meta_content($html, ['article:published_time', 'datePublished', 'uploadDate']));
media_apply_metadata_value($metadata, 'view_count', media_view_count_from_html($html));
if (preg_match_all('/<script[^>]+type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/is', $html, $matches)) {
foreach ($matches[1] as $json) {
$decoded = json_decode(html_entity_decode($json, ENT_QUOTES | ENT_HTML5, 'UTF-8'), true);
if (is_array($decoded)) media_collect_json_metadata($decoded, $metadata);
}
}
return $metadata;
}
function media_oembed_thumbnail_url(string $url, string $host): string {
$endpoint = media_provider_oembed_endpoint($url, $host);
if ($endpoint === '') return '';
$fetch = media_http_get($endpoint, 8);
if ($fetch['body'] === '' || $fetch['error'] !== '') return '';
$data = json_decode($fetch['body'], true);
if (!is_array($data)) return '';
$thumb = normalize_media_url((string)($data['thumbnail_url'] ?? ''));
return $thumb;
}
function media_provider_thumbnail_url(string $url): string {
$url = normalize_media_url($url);
if ($url === '') return '';
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
$path = (string)(parse_url($url, PHP_URL_PATH) ?? '');
$segments = media_path_segments($url);
if (media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtube-nocookie.com')) {
$id = media_youtube_id_from_url($url);
return $id !== '' ? 'https://i.ytimg.com/vi/' . rawurlencode($id) . '/hqdefault.jpg' : '';
}
if (media_host_matches($host, 'dailymotion.com') || media_host_matches($host, 'dai.ly')) {
$id = '';
if (media_host_matches($host, 'dai.ly') && isset($segments[0])) $id = $segments[0];
if ($id === '' && preg_match('#/(?:embed/)?video/([^/?#_]+)#', $path, $m)) $id = $m[1];
$id = media_clean_token($id);
if ($id !== '') return 'https://www.dailymotion.com/thumbnail/video/' . rawurlencode($id);
}
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$imgur_path = media_imgur_path_from_url($url);
if ($imgur_path !== '' && !str_contains($imgur_path, '/')) {
return 'https://i.imgur.com/' . rawurlencode($imgur_path) . 'h.jpg';
}
}
return media_oembed_thumbnail_url($url, $host);
}
function media_thumbnail_extension(string $url, string $content_type): string {
$content_type = strtolower($content_type);
if (str_contains($content_type, 'png')) return 'png';
if (str_contains($content_type, 'webp')) return 'webp';
if (str_contains($content_type, 'gif')) return 'gif';
$ext = media_extension_from_url($url);
return in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif'], true) ? ($ext === 'jpeg' ? 'jpg' : $ext) : 'jpg';
}
function media_download_thumbnail(string $url, string $slug, string $label = 'auto'): string {
$url = normalize_media_url($url);
if ($url === '') return '';
$fetch = media_http_get($url, 12);
if ($fetch['body'] === '' || $fetch['error'] !== '') return '';
if (function_exists('getimagesizefromstring') && !@getimagesizefromstring($fetch['body'])) return '';
$thumb_dir = MEDIA_DIR . 'thumbs/';
if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true);
$label = media_clean_token($label);
if ($label === '') $label = 'auto';
$ext = media_thumbnail_extension($url, (string)$fetch['content_type']);
$fname = $slug . '_' . $label . '_' . time() . '_' . substr(sha1($url), 0, 8) . '.' . $ext;
return file_put_contents($thumb_dir . $fname, $fetch['body']) !== false ? 'thumbs/' . $fname : '';
}
function media_disabled_function(string $name): bool {
$disabled = array_map('trim', explode(',', (string)ini_get('disable_functions')));
return in_array($name, $disabled, true);
}
function media_ffmpeg_path(): string {
$configured = trim((string)getenv('FFMPEG_PATH'));
if ($configured !== '' && is_executable($configured)) return $configured;
if (!function_exists('shell_exec') || media_disabled_function('shell_exec')) return '';
$path = trim((string)@shell_exec('command -v ffmpeg 2>/dev/null'));
return $path !== '' && is_executable($path) ? $path : '';
}
function media_thumbnail_from_local_video(string $file_path, string $slug): string {
$ffmpeg = media_ffmpeg_path();
if ($ffmpeg === '') return '';
$source = MEDIA_DIR . ltrim($file_path, '/');
if ($file_path === '' || !is_file($source) || !is_readable($source)) return '';
$thumb_dir = MEDIA_DIR . 'thumbs/';
if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true);
$fname = $slug . '_frame_' . time() . '_' . substr(sha1($file_path), 0, 8) . '.jpg';
$target = $thumb_dir . $fname;
if (!function_exists('exec') || media_disabled_function('exec')) return '';
$cmd = escapeshellarg($ffmpeg) . ' -y -ss 00:00:01 -i ' . escapeshellarg($source) . ' -frames:v 1 -vf ' . escapeshellarg('scale=1280:-2') . ' ' . escapeshellarg($target) . ' 2>/dev/null';
@exec($cmd, $output, $code);
if ($code === 0 && is_file($target) && filesize($target) > 0 && (!function_exists('getimagesize') || @getimagesize($target))) {
return 'thumbs/' . $fname;
}
if (is_file($target)) unlink($target);
return '';
}
function media_auto_thumbnail_for_video(int $video_id, string $slug): string {
$stmt = get_db()->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC, id ASC");
$stmt->execute([$video_id]);
$sources = $stmt->fetchAll();
foreach ($sources as $source) {
if (!source_is_remote($source)) continue;
$thumb_url = media_provider_thumbnail_url($source['source_url'] ?? '');
$thumbnail = media_download_thumbnail($thumb_url, $slug, 'auto');
if ($thumbnail !== '') return $thumbnail;
}
foreach ($sources as $source) {
if (!source_is_local($source)) continue;
$thumbnail = media_thumbnail_from_local_video($source['file_path'] ?? '', $slug);
if ($thumbnail !== '') return $thumbnail;
}
return '';
}
function media_provider_embed(string $url): ?array {
$parts = @parse_url($url);
if (!is_array($parts) || empty($parts['host'])) return null;
$host = strtolower((string)$parts['host']);
$path = isset($parts['path']) ? (string)$parts['path'] : '';
$segments = media_path_segments($url);
$query = media_query_values($url);
if (media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtube-nocookie.com')) {
$id = media_youtube_id_from_url($url);
if ($id !== '') {
$params = ['rel' => '0', 'modestbranding' => '1', 'playsinline' => '1'];
$start = isset($query['start'])
? media_parse_youtube_time(media_scalar_string($query['start']))
: (isset($query['t']) ? media_parse_youtube_time(media_scalar_string($query['t'])) : 0);
if ($start > 0) $params['start'] = (string)$start;
return ['provider' => 'youtube', 'embed' => media_append_query('https://www.youtube-nocookie.com/embed/' . rawurlencode($id), $params)];
}
if (isset($query['list'])) {
$list = media_clean_token(media_scalar_string($query['list']));
if ($list !== '') {
return ['provider' => 'youtube', 'embed' => media_append_query('https://www.youtube-nocookie.com/embed/videoseries', [
'list' => $list, 'rel' => '0', 'modestbranding' => '1', 'playsinline' => '1',
])];
}
}
}
if (media_host_matches($host, 'vimeo.com') && preg_match('#/(?:video/)?(\d+)#', $path, $m)) {
return ['provider' => 'vimeo', 'embed' => media_append_query('https://player.vimeo.com/video/' . rawurlencode($m[1]), [
'title' => '0', 'byline' => '0', 'portrait' => '0',
])];
}
if (media_host_matches($host, 'dailymotion.com') || media_host_matches($host, 'dai.ly')) {
$id = '';
if (media_host_matches($host, 'dai.ly') && isset($segments[0])) $id = $segments[0];
if ($id === '' && preg_match('#/(?:embed/)?video/([^/?#_]+)#', $path, $m)) $id = $m[1];
$id = media_clean_token($id);
if ($id !== '') return ['provider' => 'dailymotion', 'embed' => 'https://www.dailymotion.com/embed/video/' . rawurlencode($id)];
}
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$embed = media_imgur_embed_url($url);
if ($embed !== '') return ['provider' => 'imgur', 'embed' => $embed];
}
if (media_host_matches($host, 'twitch.tv')) {
$parent = media_twitch_parent_host();
if (preg_match('#/videos/(\d+)#', $path, $m)) {
return ['provider' => 'twitch', 'embed' => media_append_query('https://player.twitch.tv/', ['video' => $m[1], 'parent' => $parent, 'autoplay' => 'false'])];
}
if (preg_match('#/([^/]+)/clip/([^/?#]+)#', $path, $m)) {
return ['provider' => 'twitch', 'embed' => media_append_query('https://clips.twitch.tv/embed', ['clip' => media_clean_token($m[2]), 'parent' => $parent, 'autoplay' => 'false'])];
}
if (isset($segments[0]) && $segments[0] !== '') {
return ['provider' => 'twitch', 'embed' => media_append_query('https://player.twitch.tv/', ['channel' => media_clean_token($segments[0]), 'parent' => $parent, 'autoplay' => 'false'])];
}
}
if (media_host_matches($host, 'clips.twitch.tv') && isset($segments[0])) {
return ['provider' => 'twitch', 'embed' => media_append_query('https://clips.twitch.tv/embed', ['clip' => media_clean_token($segments[0]), 'parent' => media_twitch_parent_host(), 'autoplay' => 'false'])];
}
if (media_host_matches($host, 'facebook.com') || media_host_matches($host, 'fb.watch')) {
return ['provider' => 'facebook', 'embed' => media_append_query('https://www.facebook.com/plugins/video.php', ['href' => $url, 'show_text' => 'false', 'width' => '1280'])];
}
if (media_host_matches($host, 'tiktok.com') && preg_match('#/video/(\d+)#', $path, $m)) {
return ['provider' => 'tiktok', 'embed' => 'https://www.tiktok.com/embed/v2/' . rawurlencode($m[1])];
}
if (media_host_matches($host, 'instagram.com') && preg_match('#/(p|reel|tv)/([^/?#]+)#', $path, $m)) {
return ['provider' => 'instagram', 'embed' => 'https://www.instagram.com/' . rawurlencode($m[1]) . '/' . rawurlencode($m[2]) . '/embed'];
}
if ((media_host_matches($host, 'twitter.com') || media_host_matches($host, 'x.com')) && preg_match('#/status/(\d+)#', $path, $m)) {
return ['provider' => 'twitter', 'embed' => media_append_query('https://platform.twitter.com/embed/Tweet.html', ['id' => $m[1]])];
}
if (media_host_matches($host, 'soundcloud.com')) {
return ['provider' => 'soundcloud', 'embed' => media_append_query('https://w.soundcloud.com/player/', ['url' => $url, 'auto_play' => 'false', 'show_teaser' => 'true'])];
}
if (media_host_matches($host, 'open.spotify.com') && isset($segments[0], $segments[1])) {
$type = media_clean_token($segments[0]);
$id = media_clean_token($segments[1]);
if (in_array($type, ['album', 'artist', 'episode', 'playlist', 'show', 'track'], true) && $id !== '') {
return ['provider' => 'spotify', 'embed' => 'https://open.spotify.com/embed/' . rawurlencode($type) . '/' . rawurlencode($id)];
}
}
if (media_host_matches($host, 'drive.google.com')) {
$id = '';
if (preg_match('#/file/d/([^/]+)#', $path, $m)) $id = $m[1];
elseif (isset($query['id'])) $id = media_scalar_string($query['id']);
$id = media_clean_token($id);
if ($id !== '') return ['provider' => 'google-drive', 'embed' => 'https://drive.google.com/file/d/' . rawurlencode($id) . '/preview'];
}
if ((media_host_matches($host, 'wistia.com') || media_host_matches($host, 'wi.st')) && preg_match('#/(?:medias/)?([A-Za-z0-9]+)#', $path, $m)) {
return ['provider' => 'wistia', 'embed' => 'https://fast.wistia.net/embed/iframe/' . rawurlencode($m[1])];
}
if (media_host_matches($host, 'streamable.com') && isset($segments[0])) {
return ['provider' => 'streamable', 'embed' => 'https://streamable.com/e/' . rawurlencode(media_clean_token($segments[0]))];
}
if (media_host_matches($host, 'loom.com') && isset($segments[0], $segments[1]) && $segments[0] === 'share') {
return ['provider' => 'loom', 'embed' => 'https://www.loom.com/embed/' . rawurlencode(media_clean_token($segments[1]))];
}
return null;
}
function media_rewrite_direct_url(string $url): string {
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
$path = (string)(parse_url($url, PHP_URL_PATH) ?? '');
if (media_host_matches($host, 'dropbox.com')) {
$parts = @parse_url($url);
if (is_array($parts)) {
$query = [];
if (isset($parts['query'])) parse_str((string)$parts['query'], $query);
unset($query['dl'], $query['raw']);
$query['raw'] = '1';
$rebuilt = ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? 'www.dropbox.com');
$rebuilt .= $parts['path'] ?? '';
$rebuilt = media_append_query($rebuilt, $query);
if (isset($parts['fragment'])) $rebuilt .= '#' . $parts['fragment'];
return $rebuilt;
}
}
if (media_host_matches($host, 'github.com') && preg_match('#^/([^/]+)/([^/]+)/blob/([^/]+)/(.+)$#', $path, $m)) {
return 'https://raw.githubusercontent.com/' . rawurlencode($m[1]) . '/' . rawurlencode($m[2]) . '/' . rawurlencode($m[3]) . '/' . str_replace('%2F', '/', rawurlencode($m[4]));
}
return $url;
}
function media_classify_kind(string $url, string $mime, string $forced_mode = 'auto'): string {
$forced_mode = strtolower($forced_mode);
if (in_array($forced_mode, ['video', 'audio', 'hls', 'dash', 'iframe'], true)) return $forced_mode;
$ext = media_extension_from_url($url);
$mime_lc = strtolower($mime);
if ($ext === 'm3u8' || in_array($mime_lc, ['application/x-mpegurl', 'application/vnd.apple.mpegurl'], true)) return 'hls';
if ($ext === 'mpd' || $mime_lc === 'application/dash+xml') return 'dash';
if (str_starts_with($mime_lc, 'audio/')) return 'audio';
if (str_starts_with($mime_lc, 'video/')) return 'video';
if (in_array($ext, ['mp3', 'm4a', 'aac', 'wav', 'flac', 'oga', 'opus'], true)) return 'audio';
if (in_array($ext, ['mp4', 'm4v', 'webm', 'ogv', 'ogg', 'mov', 'mkv', 'avi', 'wmv', 'flv', 'ts', '3gp', '3g2'], true)) return 'video';
return 'unknown';
}
function source_storage_mime_from_url(string $url): string {
$url = normalize_media_url($url);
if ($url === '') return 'video/mp4';
if (media_provider_embed($url)) return 'text/html';
$direct = media_rewrite_direct_url($url);
return media_guess_mime($direct) ?: 'text/html';
}
function source_is_remote(array $source): bool {
return ($source['source_type'] ?? 'local') === 'remote';
}
function source_is_local(array $source): bool {
return !source_is_remote($source);
}
function source_location(array $source): string {
return source_is_remote($source) ? ($source['source_url'] ?? '') : ($source['file_path'] ?? '');
}
function source_public_url(array $source, string $media_base = MEDIA_URL): string {
if (source_is_remote($source)) {
return $source['source_url'] ?? '';
}
return rtrim($media_base, '/') . '/' . ltrim($source['file_path'] ?? '', '/');
}
function source_player_data(array $source, string $media_base = MEDIA_URL): ?array {
$is_remote = source_is_remote($source);
$original_url = $is_remote ? normalize_media_url($source['source_url'] ?? '') : source_public_url($source, $media_base);
if ($original_url === '') return null;
if ($is_remote) {
$provider = media_provider_embed($original_url);
$play_url = $provider ? $original_url : media_rewrite_direct_url($original_url);
$mime = $provider ? 'text/html' : trim($source['mime_type'] ?? '');
if ($mime === '' || $mime === 'text/html') $mime = $provider ? 'text/html' : media_guess_mime($play_url);
$kind = $provider ? 'iframe' : media_classify_kind($play_url, $mime);
if ($kind === 'unknown') {
$kind = 'iframe';
$mime = 'text/html';
}
$embed = $provider['embed'] ?? $play_url;
$provider_name = $provider['provider'] ?? '';
} else {
$play_url = $original_url;
$mime = trim($source['mime_type'] ?? '') ?: mime_from_ext($play_url);
$kind = media_classify_kind($play_url, $mime);
$embed = $play_url;
$provider_name = '';
}
$label = trim($source['label'] ?? '') ?: trim($source['quality'] ?? '');
if ($label === '') $label = $provider_name ?: (media_extension_from_url($play_url) ?: $kind);
return [
'url' => $play_url,
'original_url' => $original_url,
'embed' => $embed,
'provider' => $provider_name,
'mime' => $mime,
'kind' => $kind,
'label' => $label,
];
}
function source_provider_label(array $source): string {
$player = source_player_data($source);
if (!$player) return source_is_remote($source) ? 'External URL' : 'Uploaded File';
if (($player['provider'] ?? '') !== '') return ucwords(str_replace('-', ' ', $player['provider'])) . ' Embed';
if (($player['kind'] ?? '') === 'iframe') return 'External Embed';
return source_is_remote($source) ? 'External URL' : 'Uploaded File';
}
function format_duration(int $seconds): string {
if ($seconds <= 0) return '';
$h = intdiv($seconds, 3600);
$m = intdiv($seconds % 3600, 60);
$s = $seconds % 60;
if ($h) return sprintf('%d:%02d:%02d', $h, $m, $s);
return sprintf('%d:%02d', $m, $s);
}
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function get_videos_paginated(int $page, int $per_page, bool $published_only = true, string $search = ''): array {
$db = get_db();
$where_parts = [];
$bind = [];
$search = trim($search);
if ($published_only) {
$where_parts[] = 'v.published=1';
}
if ($search !== '') {
$where_parts[] = '(v.title LIKE :q OR v.description LIKE :q OR v.slug LIKE :q)';
$bind[':q'] = '%' . $search . '%';
}
$where = $where_parts ? 'WHERE ' . implode(' AND ', $where_parts) : '';
$count = $db->prepare("SELECT COUNT(*) FROM videos v $where");
foreach ($bind as $key => $value) $count->bindValue($key, $value);
$count->execute();
$total = $count->fetchColumn();
$total_pages = (int)ceil($total / $per_page);
if ($total_pages > 0) {
$page = min($page, $total_pages);
}
$offset = ($page - 1) * $per_page;
$rows = $db->prepare("
SELECT v.*, GROUP_CONCAT(CASE WHEN vs.source_type='remote' THEN vs.source_url ELSE vs.file_path END,'||') AS source_paths
FROM videos v
LEFT JOIN video_sources vs ON vs.video_id = v.id
$where
GROUP BY v.id
ORDER BY v.sort_order ASC, v.id DESC
LIMIT :limit OFFSET :offset
");
foreach ($bind as $key => $value) $rows->bindValue($key, $value);
$rows->bindValue(':limit', $per_page, PDO::PARAM_INT);
$rows->bindValue(':offset', $offset, PDO::PARAM_INT);
$rows->execute();
return [
'videos' => $rows->fetchAll(),
'total' => (int)$total,
'page' => $page,
'per_page' => $per_page,
'total_pages' => $total_pages,
];
}
function get_video_by_slug(string $slug): ?array {
$db = get_db();
$row = $db->prepare("SELECT * FROM videos WHERE slug=? AND published=1");
$row->execute([$slug]);
$v = $row->fetch();
if (!$v) return null;
$sources = $db->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC");
$sources->execute([$v['id']]);
$v['sources'] = $sources->fetchAll();
return $v;
}
function get_video_by_id(int $id): ?array {
$db = get_db();
$row = $db->prepare("SELECT * FROM videos WHERE id=?");
$row->execute([$id]);
$v = $row->fetch();
if (!$v) return null;
$sources = $db->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC");
$sources->execute([$v['id']]);
$v['sources'] = $sources->fetchAll();
return $v;
}