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, 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_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', ]; $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_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 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_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_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 = ''; 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]; $id = media_clean_token($id); 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, 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; }