6a1c545f35
Public player descriptions render formatted HTML on the homepage. Catalogue cards, embed meta tags, and social metadata use safe plain-text excerpts. Add/edit admin pages now use a rich description editor that preserves pasted HTML styling, with a textarea fallback for no-JS. External/YouTube-imported descriptions are sanitized before storage. README now documents Markdown/HTML description support.
1965 lines
75 KiB
PHP
1965 lines
75 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_created ON videos(created_at, 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_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_description_contains_html(string $value): bool {
|
|
return (bool)preg_match('/<\/?[a-z][a-z0-9:-]*(?:\s[^>]*)?>/i', $value);
|
|
}
|
|
|
|
function media_pick_longest_description(string $current, string $candidate): string {
|
|
$candidate = trim($candidate);
|
|
if ($candidate === '') return $current;
|
|
$current_length = strlen(media_description_plain_text($current));
|
|
$candidate_length = strlen(media_description_plain_text($candidate));
|
|
return $candidate_length > $current_length ? $candidate : $current;
|
|
}
|
|
|
|
function media_prepare_description_for_storage(string $value): string {
|
|
$value = trim($value);
|
|
if ($value === '') return '';
|
|
return media_description_contains_html($value) ? media_sanitize_html($value) : $value;
|
|
}
|
|
|
|
function media_description_plain_text(string $value): string {
|
|
$value = trim($value);
|
|
if ($value === '') return '';
|
|
$html = media_description_html($value);
|
|
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$text = preg_replace('/[ \t]+/', ' ', $text);
|
|
$text = preg_replace('/\s*\n\s*/', ' ', (string)$text);
|
|
return trim((string)$text);
|
|
}
|
|
|
|
function media_description_html(string $value): string {
|
|
$value = trim($value);
|
|
if ($value === '') return '';
|
|
$html = media_description_contains_html($value)
|
|
? $value
|
|
: media_markdown_to_html($value);
|
|
return media_sanitize_html($html);
|
|
}
|
|
|
|
function media_markdown_to_html(string $markdown): string {
|
|
$markdown = str_replace(["\r\n", "\r"], "\n", trim($markdown));
|
|
if ($markdown === '') return '';
|
|
|
|
$lines = explode("\n", $markdown);
|
|
$html = [];
|
|
$paragraph = [];
|
|
$blockquote = [];
|
|
$list_type = '';
|
|
$in_code = false;
|
|
$code_lines = [];
|
|
|
|
$flush_paragraph = function () use (&$html, &$paragraph): void {
|
|
if (!$paragraph) return;
|
|
$parts = array_map('media_markdown_inline', $paragraph);
|
|
$html[] = '<p>' . implode('<br>', $parts) . '</p>';
|
|
$paragraph = [];
|
|
};
|
|
$flush_blockquote = function () use (&$html, &$blockquote): void {
|
|
if (!$blockquote) return;
|
|
$parts = array_map('media_markdown_inline', $blockquote);
|
|
$html[] = '<blockquote><p>' . implode('<br>', $parts) . '</p></blockquote>';
|
|
$blockquote = [];
|
|
};
|
|
$close_list = function () use (&$html, &$list_type): void {
|
|
if ($list_type === '') return;
|
|
$html[] = '</' . $list_type . '>';
|
|
$list_type = '';
|
|
};
|
|
|
|
foreach ($lines as $line) {
|
|
if (preg_match('/^\s*```/', $line)) {
|
|
if ($in_code) {
|
|
$html[] = '<pre><code>' . h(implode("\n", $code_lines)) . '</code></pre>';
|
|
$code_lines = [];
|
|
$in_code = false;
|
|
} else {
|
|
$flush_paragraph();
|
|
$flush_blockquote();
|
|
$close_list();
|
|
$in_code = true;
|
|
$code_lines = [];
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($in_code) {
|
|
$code_lines[] = $line;
|
|
continue;
|
|
}
|
|
|
|
if (trim($line) === '') {
|
|
$flush_paragraph();
|
|
$flush_blockquote();
|
|
$close_list();
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^(#{1,6})\s+(.+)$/', $line, $match)) {
|
|
$flush_paragraph();
|
|
$flush_blockquote();
|
|
$close_list();
|
|
$level = strlen($match[1]);
|
|
$html[] = '<h' . $level . '>' . media_markdown_inline($match[2]) . '</h' . $level . '>';
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^>\s?(.*)$/', $line, $match)) {
|
|
$flush_paragraph();
|
|
$close_list();
|
|
$blockquote[] = $match[1];
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^\s*[-*+]\s+(.+)$/', $line, $match)) {
|
|
$flush_paragraph();
|
|
$flush_blockquote();
|
|
if ($list_type !== 'ul') {
|
|
$close_list();
|
|
$html[] = '<ul>';
|
|
$list_type = 'ul';
|
|
}
|
|
$html[] = '<li>' . media_markdown_inline($match[1]) . '</li>';
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^\s*\d+[.)]\s+(.+)$/', $line, $match)) {
|
|
$flush_paragraph();
|
|
$flush_blockquote();
|
|
if ($list_type !== 'ol') {
|
|
$close_list();
|
|
$html[] = '<ol>';
|
|
$list_type = 'ol';
|
|
}
|
|
$html[] = '<li>' . media_markdown_inline($match[1]) . '</li>';
|
|
continue;
|
|
}
|
|
|
|
$close_list();
|
|
$flush_blockquote();
|
|
$paragraph[] = $line;
|
|
}
|
|
|
|
if ($in_code) $html[] = '<pre><code>' . h(implode("\n", $code_lines)) . '</code></pre>';
|
|
$flush_paragraph();
|
|
$flush_blockquote();
|
|
$close_list();
|
|
|
|
return implode("\n", $html);
|
|
}
|
|
|
|
function media_markdown_inline(string $text): string {
|
|
$tokens = [];
|
|
$text = preg_replace_callback('/`([^`\n]+)`/', function ($match) use (&$tokens): string {
|
|
$token = '@@MDTOKEN' . count($tokens) . '@@';
|
|
$tokens[$token] = '<code>' . h($match[1]) . '</code>';
|
|
return $token;
|
|
}, $text);
|
|
|
|
$html = h((string)$text);
|
|
|
|
$html = preg_replace_callback('/\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/', function ($match): string {
|
|
$url = html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
return media_description_link_html($url, $match[1]);
|
|
}, $html);
|
|
|
|
$html = preg_replace('/\*\*([^*\n]+)\*\*/', '<strong>$1</strong>', (string)$html);
|
|
$html = preg_replace('/__([^_\n]+)__/', '<strong>$1</strong>', (string)$html);
|
|
$html = preg_replace('/(?<!\*)\*([^*\n]+)\*(?!\*)/', '<em>$1</em>', (string)$html);
|
|
$html = preg_replace('/(?<!_)_([^_\n]+)_(?!_)/', '<em>$1</em>', (string)$html);
|
|
$html = preg_replace('/~~([^~\n]+)~~/', '<s>$1</s>', (string)$html);
|
|
|
|
foreach ($tokens as $token => $replacement) {
|
|
$html = str_replace($token, $replacement, (string)$html);
|
|
}
|
|
|
|
return media_autolink_html_text((string)$html);
|
|
}
|
|
|
|
function media_autolink_html_text(string $html): string {
|
|
$parts = preg_split('/(<a\b[^>]*>.*?<\/a>|<code\b[^>]*>.*?<\/code>|<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
|
|
if (!is_array($parts)) return $html;
|
|
|
|
foreach ($parts as $i => $part) {
|
|
if ($part === '' || str_starts_with($part, '<')) continue;
|
|
$parts[$i] = preg_replace_callback('~https?://[^\s<]+~i', function ($match): string {
|
|
$url = $match[0];
|
|
$trail = '';
|
|
while ($url !== '' && preg_match('/[.,;:!?)]$/', $url)) {
|
|
$trail = substr($url, -1) . $trail;
|
|
$url = substr($url, 0, -1);
|
|
}
|
|
return media_description_link_html($url, h($url)) . $trail;
|
|
}, $part);
|
|
}
|
|
|
|
return implode('', $parts);
|
|
}
|
|
|
|
function media_description_link_html(string $url, string $label_html): string {
|
|
$href = media_clean_url_attribute($url, false);
|
|
if ($href === '') return $label_html;
|
|
return '<a href="' . h($href) . '" target="_blank" rel="noopener noreferrer">' . $label_html . '</a>';
|
|
}
|
|
|
|
function media_clean_url_attribute(string $url, bool $for_src): string {
|
|
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
|
$url = preg_replace('/[\x00-\x1F\x7F\s]+/', '', $url);
|
|
$url = (string)$url;
|
|
if ($url === '') return '';
|
|
|
|
if (str_starts_with($url, '//')) return 'https:' . $url;
|
|
if (str_starts_with($url, '#')) return $for_src ? '' : $url;
|
|
if (str_starts_with($url, '/')) return $url;
|
|
|
|
if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url, $match)) {
|
|
$scheme = strtolower(rtrim($match[0], ':'));
|
|
$allowed = $for_src ? ['http', 'https'] : ['http', 'https', 'mailto', 'tel'];
|
|
return in_array($scheme, $allowed, true) ? $url : '';
|
|
}
|
|
|
|
return preg_match('/^[^<>"\']+$/', $url) ? $url : '';
|
|
}
|
|
|
|
function media_clean_style_attribute(string $style): string {
|
|
$allowed = [
|
|
'background-color' => true,
|
|
'color' => true,
|
|
'font-size' => true,
|
|
'font-style' => true,
|
|
'font-weight' => true,
|
|
'letter-spacing' => true,
|
|
'line-height' => true,
|
|
'text-align' => true,
|
|
'text-decoration' => true,
|
|
'text-transform' => true,
|
|
];
|
|
$clean = [];
|
|
|
|
foreach (explode(';', $style) as $rule) {
|
|
if (!str_contains($rule, ':')) continue;
|
|
[$property, $value] = array_map('trim', explode(':', $rule, 2));
|
|
$property = strtolower($property);
|
|
if (!isset($allowed[$property]) || $value === '') continue;
|
|
if (preg_match('/url\s*\(|expression\s*\(|javascript:|data:|@|[<>\\\\]/i', $value)) continue;
|
|
if (strlen($value) > 160) continue;
|
|
$clean[] = $property . ': ' . $value;
|
|
}
|
|
|
|
return implode('; ', $clean);
|
|
}
|
|
|
|
function media_description_allowed_tags(): array {
|
|
return [
|
|
'a' => true, 'b' => true, 'blockquote' => true, 'br' => true,
|
|
'code' => true, 'div' => true, 'em' => true, 'h1' => true,
|
|
'h2' => true, 'h3' => true, 'h4' => true, 'h5' => true,
|
|
'h6' => true, 'i' => true, 'img' => true, 'li' => true,
|
|
'ol' => true, 'p' => true, 'pre' => true, 's' => true,
|
|
'span' => true, 'strong' => true, 'u' => true, 'ul' => true,
|
|
];
|
|
}
|
|
|
|
function media_description_drop_tags(): array {
|
|
return [
|
|
'audio' => true, 'base' => true, 'button' => true, 'canvas' => true,
|
|
'embed' => true, 'form' => true, 'iframe' => true, 'input' => true,
|
|
'link' => true, 'math' => true, 'meta' => true, 'object' => true,
|
|
'picture' => true, 'script' => true, 'select' => true, 'source' => true,
|
|
'style' => true, 'svg' => true, 'textarea' => true, 'track' => true,
|
|
'video' => true,
|
|
];
|
|
}
|
|
|
|
function media_description_allowed_attrs(string $tag): array {
|
|
$attrs = ['title' => true, 'style' => true];
|
|
if ($tag === 'a') {
|
|
$attrs['href'] = true;
|
|
$attrs['rel'] = true;
|
|
$attrs['target'] = true;
|
|
}
|
|
if ($tag === 'img') {
|
|
$attrs['src'] = true;
|
|
$attrs['alt'] = true;
|
|
$attrs['width'] = true;
|
|
$attrs['height'] = true;
|
|
$attrs['loading'] = true;
|
|
$attrs['decoding'] = true;
|
|
}
|
|
if ($tag === 'ol') $attrs['start'] = true;
|
|
return $attrs;
|
|
}
|
|
|
|
function media_sanitize_html(string $html): string {
|
|
$html = trim($html);
|
|
if ($html === '') return '';
|
|
if (!class_exists('DOMDocument')) {
|
|
return nl2br(h(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
|
}
|
|
|
|
$doc = new DOMDocument('1.0', 'UTF-8');
|
|
$previous = function_exists('libxml_use_internal_errors') ? libxml_use_internal_errors(true) : null;
|
|
$options = 0;
|
|
if (defined('LIBXML_HTML_NOIMPLIED')) $options |= LIBXML_HTML_NOIMPLIED;
|
|
if (defined('LIBXML_HTML_NODEFDTD')) $options |= LIBXML_HTML_NODEFDTD;
|
|
|
|
$loaded = $doc->loadHTML(
|
|
'<?xml encoding="UTF-8"><div id="media-description-root">' . $html . '</div>',
|
|
$options
|
|
);
|
|
if (function_exists('libxml_clear_errors')) libxml_clear_errors();
|
|
if ($previous !== null && function_exists('libxml_use_internal_errors')) libxml_use_internal_errors($previous);
|
|
if (!$loaded) return '';
|
|
|
|
$root = $doc->getElementById('media-description-root');
|
|
if (!$root) return '';
|
|
|
|
media_sanitize_dom_node($root, $doc);
|
|
|
|
$out = '';
|
|
foreach ($root->childNodes as $child) {
|
|
$out .= $doc->saveHTML($child);
|
|
}
|
|
return trim($out);
|
|
}
|
|
|
|
function media_sanitize_dom_node($node, $doc): void {
|
|
$allowed_tags = media_description_allowed_tags();
|
|
$drop_tags = media_description_drop_tags();
|
|
|
|
for ($child = $node->firstChild; $child !== null; $child = $next) {
|
|
$next = $child->nextSibling;
|
|
|
|
if ($child->nodeType === 8) {
|
|
$node->removeChild($child);
|
|
continue;
|
|
}
|
|
|
|
if ($child->nodeType !== 1) continue;
|
|
|
|
$tag = strtolower($child->nodeName);
|
|
if (isset($drop_tags[$tag])) {
|
|
$node->removeChild($child);
|
|
continue;
|
|
}
|
|
|
|
media_sanitize_dom_node($child, $doc);
|
|
|
|
if (!isset($allowed_tags[$tag])) {
|
|
while ($child->firstChild) {
|
|
$node->insertBefore($child->firstChild, $child);
|
|
}
|
|
$node->removeChild($child);
|
|
continue;
|
|
}
|
|
|
|
media_sanitize_dom_attributes($child, $tag);
|
|
if ($tag === 'img' && !$child->hasAttribute('src')) {
|
|
$node->removeChild($child);
|
|
}
|
|
}
|
|
}
|
|
|
|
function media_sanitize_dom_attributes($element, string $tag): void {
|
|
$allowed = media_description_allowed_attrs($tag);
|
|
$attrs = [];
|
|
if ($element->hasAttributes()) {
|
|
foreach ($element->attributes as $attr) $attrs[] = $attr;
|
|
}
|
|
|
|
foreach ($attrs as $attr) {
|
|
$name = strtolower($attr->nodeName);
|
|
$value = (string)$attr->nodeValue;
|
|
if (str_starts_with($name, 'on') || !isset($allowed[$name])) {
|
|
$element->removeAttribute($attr->nodeName);
|
|
continue;
|
|
}
|
|
|
|
if ($name === 'style') {
|
|
$clean = media_clean_style_attribute($value);
|
|
if ($clean === '') $element->removeAttribute($attr->nodeName);
|
|
else $element->setAttribute('style', $clean);
|
|
continue;
|
|
}
|
|
|
|
if ($name === 'href' || $name === 'src') {
|
|
$clean = media_clean_url_attribute($value, $name === 'src');
|
|
if ($clean === '') $element->removeAttribute($attr->nodeName);
|
|
else $element->setAttribute($name, $clean);
|
|
continue;
|
|
}
|
|
|
|
if (in_array($name, ['width', 'height', 'start'], true)) {
|
|
$number = max(0, min(4000, (int)$value));
|
|
if ($number <= 0) $element->removeAttribute($attr->nodeName);
|
|
else $element->setAttribute($name, (string)$number);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if ($tag === 'a') {
|
|
if ($element->hasAttribute('href')) {
|
|
$element->setAttribute('target', '_blank');
|
|
$element->setAttribute('rel', 'noopener noreferrer');
|
|
} else {
|
|
$element->removeAttribute('target');
|
|
$element->removeAttribute('rel');
|
|
}
|
|
}
|
|
|
|
if ($tag === 'img') {
|
|
if (!$element->hasAttribute('src')) return;
|
|
$element->setAttribute('loading', 'lazy');
|
|
$element->setAttribute('decoding', 'async');
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
$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_catalogue_order_candidate_count(): int {
|
|
return (int)get_db()->query("SELECT COUNT(*) FROM videos")->fetchColumn();
|
|
}
|
|
|
|
function media_correct_catalogue_sort_order(): array {
|
|
$db = get_db();
|
|
$videos = $db->query("
|
|
SELECT id, sort_order
|
|
FROM videos
|
|
ORDER BY datetime(created_at) DESC, created_at DESC, id DESC
|
|
")->fetchAll();
|
|
|
|
$result = [
|
|
'checked' => count($videos),
|
|
'updated' => 0,
|
|
];
|
|
if (!$videos) return $result;
|
|
|
|
$stmt = $db->prepare("UPDATE videos SET sort_order=?, updated_at=datetime('now') WHERE id=?");
|
|
$db->beginTransaction();
|
|
try {
|
|
foreach ($videos as $index => $video) {
|
|
$sort_order = $index * 10;
|
|
if ((int)$video['sort_order'] === $sort_order) continue;
|
|
$stmt->execute([$sort_order, (int)$video['id']]);
|
|
$result['updated']++;
|
|
}
|
|
$db->commit();
|
|
} catch (Throwable $e) {
|
|
$db->rollBack();
|
|
throw $e;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
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, '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;
|
|
}
|
|
|
|
if ($key === 'description') {
|
|
$description = media_prepare_description_for_storage((string)$value);
|
|
if ($description === '') return;
|
|
$metadata['description'] = media_pick_longest_description((string)$metadata['description'], $description);
|
|
return;
|
|
}
|
|
|
|
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
|
if ($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) ?? ''));
|
|
$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);
|
|
}
|
|
|
|
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_black_video_from_m4a(string $file_path, string $slug, int $index = 0): string {
|
|
$ffmpeg = media_ffmpeg_path();
|
|
if ($ffmpeg === '') return '';
|
|
|
|
$source = MEDIA_DIR . ltrim($file_path, '/');
|
|
if ($file_path === '' || !is_file($source) || !is_readable($source)) return '';
|
|
if (strtolower(pathinfo($source, PATHINFO_EXTENSION)) !== 'm4a') return '';
|
|
|
|
$videos_dir = MEDIA_DIR . 'videos/';
|
|
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
|
|
if (!function_exists('exec') || media_disabled_function('exec')) return '';
|
|
|
|
$label = media_clean_token($slug);
|
|
if ($label === '') $label = 'video';
|
|
$fname = $label . '_m4a_black_' . $index . '_' . time() . '_' . substr(sha1($file_path), 0, 8) . '.mp4';
|
|
$target = $videos_dir . $fname;
|
|
|
|
$cmd = escapeshellarg($ffmpeg)
|
|
. ' -y -f lavfi -i ' . escapeshellarg('color=c=black:s=1280x720:r=30')
|
|
. ' -i ' . escapeshellarg($source)
|
|
. ' -shortest -map 0:v:0 -map 1:a:0'
|
|
. ' -c:v libx264 -preset veryfast -tune stillimage -pix_fmt yuv420p'
|
|
. ' -c:a aac -b:a 192k -movflags +faststart '
|
|
. escapeshellarg($target) . ' 2>/dev/null';
|
|
@exec($cmd, $output, $code);
|
|
|
|
if ($code === 0 && is_file($target) && filesize($target) > 0) {
|
|
return 'videos/' . $fname;
|
|
}
|
|
|
|
if (is_file($target)) unlink($target);
|
|
return '';
|
|
}
|
|
|
|
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, '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, datetime(v.created_at) DESC, v.created_at DESC, 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;
|
|
}
|