@@ -183,6 +200,29 @@ render_head('Admin — TyClifford.com', '
+
+

Upkeeping

+
+
+

Correct YouTube timestamps

+

+ Fetches original YouTube publish dates and updates the database timestamp shown in the admin list and public catalogue. + YouTube-backed video currently match this upkeep task. +

+
+
+ +
+ + +
+ +
+
+
+
Duration Sources Status - Added + Date Actions diff --git a/admin/youtube_import.php b/admin/youtube_import.php index bf29420..716a296 100644 --- a/admin/youtube_import.php +++ b/admin/youtube_import.php @@ -486,10 +486,8 @@ function yt_import_videos(PDO $db, array $videos, array $selected_ids, bool $pub try { $slug = make_slug($video['title']); $thumbnail = yt_download_thumbnail($video['thumbnail_url'], $slug); - $db->prepare(" - INSERT INTO videos (title, description, slug, thumbnail, duration, sort_order, published) - VALUES (?, ?, ?, ?, ?, ?, ?) - ")->execute([ + $published_at = media_youtube_publish_datetime($video['watch_url']); + $video_params = [ $video['title'], $video['description'], $slug, @@ -497,7 +495,19 @@ function yt_import_videos(PDO $db, array $videos, array $selected_ids, bool $pub (int)$video['duration'], 0, $published ? 1 : 0, - ]); + ]; + if ($published_at !== '') { + $video_params[] = $published_at; + $db->prepare(" + INSERT INTO videos (title, description, slug, thumbnail, duration, sort_order, published, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ")->execute($video_params); + } else { + $db->prepare(" + INSERT INTO videos (title, description, slug, thumbnail, duration, sort_order, published) + VALUES (?, ?, ?, ?, ?, ?, ?) + ")->execute($video_params); + } $video_id = (int)$db->lastInsertId(); $db->prepare(" INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order) diff --git a/includes/db.php b/includes/db.php index 328fc24..5602f58 100644 --- a/includes/db.php +++ b/includes/db.php @@ -306,13 +306,17 @@ function media_append_query(string $base, array $params): string { return $base . (strpos($base, '?') === false ? '?' : '&') . $query; } -function media_http_get(string $url, int $timeout = 12): array { +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: 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', + 'Accept: ' . $accept, + 'Accept-Language: en-US,en;q=0.9', ]; if (function_exists('curl_init')) { @@ -411,6 +415,144 @@ function media_youtube_id_from_url(string $url): string { 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 = [ + '/]+itemprop=["\']datePublished["\'][^>]+content=["\']([^"\']+)["\']/i', + '/]+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_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);