-Implemented YouTube timestamp correction and added the admin Upkeeping section.
What changed: Added shared YouTube publish-date scraping helpers in [includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php). New YouTube imports now set videos.created_at to the original YouTube publish date when available. Manual Add/Edit saves now also try to correct timestamps for YouTube-backed videos. Added Admin Dashboard → Upkeeping → Correct YouTube timestamps in [admin/index.php](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/index.php), with batch size control for backfilling existing records. Renamed the admin table column from Added to Date, since it may now reflect original publish date. Documented the behavior in [README.md](/Users/tyemeclifford/Documents/GH/mediaplayer/README.md).
This commit is contained in:
@@ -84,6 +84,8 @@ If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the
|
|||||||
|
|
||||||
Use **Admin → YouTube Importer** to paste a YouTube channel URL, `@handle`, or `UC...` channel ID. The importer scrapes the channel's `/videos` page, extracts video data from `ytInitialData`, previews the results, and imports selected videos as remote YouTube embed sources. Duplicate YouTube watch URLs are skipped.
|
Use **Admin → YouTube Importer** to paste a YouTube channel URL, `@handle`, or `UC...` channel ID. The importer scrapes the channel's `/videos` page, extracts video data from `ytInitialData`, previews the results, and imports selected videos as remote YouTube embed sources. Duplicate YouTube watch URLs are skipped.
|
||||||
|
|
||||||
|
Imported YouTube videos use the video's original publish date for the database `created_at` timestamp when YouTube exposes it. The admin dashboard also includes **Upkeeping → Correct YouTube timestamps** to backfill existing YouTube entries in batches.
|
||||||
|
|
||||||
### Public Search
|
### Public Search
|
||||||
|
|
||||||
The full catalogue page includes a public search for published videos only. Search matches video title, description, and slug; draft videos remain hidden.
|
The full catalogue page includes a public search for published videos only. Search matches video title, description, and slug; draft videos remain hidden.
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$db->prepare("UPDATE videos SET thumbnail=? WHERE id=?")->execute([$thumbnail, $video_id]);
|
$db->prepare("UPDATE videos SET thumbnail=? WHERE id=?")->execute([$thumbnail, $video_id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
media_apply_youtube_timestamp($video_id);
|
||||||
|
|
||||||
header('Location: edit.php?id=' . $video_id . '&saved=1');
|
header('Location: edit.php?id=' . $video_id . '&saved=1');
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
media_apply_youtube_timestamp($id);
|
||||||
|
|
||||||
header('Location: edit.php?id=' . $id . '&saved=1');
|
header('Location: edit.php?id=' . $id . '&saved=1');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-1
@@ -39,6 +39,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$db->prepare("UPDATE videos SET published=? WHERE id=?")->execute([$cur ? 0 : 1, $id]);
|
$db->prepare("UPDATE videos SET published=? WHERE id=?")->execute([$cur ? 0 : 1, $id]);
|
||||||
$msg = 'Video ' . ($cur ? 'unpublished' : 'published') . '.';
|
$msg = 'Video ' . ($cur ? 'unpublished' : 'published') . '.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === 'correct_youtube_timestamps') {
|
||||||
|
$limit = max(1, min(100, (int)($_POST['timestamp_limit'] ?? 25)));
|
||||||
|
$result = media_correct_youtube_timestamps($limit);
|
||||||
|
$msg = 'Upkeeping checked ' . $result['checked'] . ' YouTube video' . ($result['checked'] === 1 ? '' : 's') . ': ';
|
||||||
|
$msg .= $result['corrected'] . ' corrected';
|
||||||
|
if ($result['already_correct']) $msg .= ', ' . $result['already_correct'] . ' already correct';
|
||||||
|
if ($result['not_found']) $msg .= ', ' . $result['not_found'] . ' without a readable original date';
|
||||||
|
$msg .= '.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search + pagination
|
// Search + pagination
|
||||||
@@ -73,6 +83,7 @@ $list_stmt->bindValue(':limit', $per_page, PDO::PARAM_INT);
|
|||||||
$list_stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
$list_stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
$list_stmt->execute();
|
$list_stmt->execute();
|
||||||
$videos = $list_stmt->fetchAll();
|
$videos = $list_stmt->fetchAll();
|
||||||
|
$youtube_timestamp_candidates = media_youtube_timestamp_candidate_count();
|
||||||
|
|
||||||
$qs = fn($p) => '?' . http_build_query(array_filter(['q' => $search, 'page' => $p]));
|
$qs = fn($p) => '?' . http_build_query(array_filter(['q' => $search, 'page' => $p]));
|
||||||
|
|
||||||
@@ -130,6 +141,12 @@ render_head('Admin — TyClifford.com', '
|
|||||||
|
|
||||||
.admin-footer-bar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
.admin-footer-bar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
||||||
.rec-count { font-family:"Share Tech Mono",monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; }
|
.rec-count { font-family:"Share Tech Mono",monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; }
|
||||||
|
.section-heading { font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.15em; text-transform:uppercase; color:var(--text-3); margin-bottom:.75rem; }
|
||||||
|
.hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; line-height:1.5; }
|
||||||
|
.upkeep-row { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
||||||
|
.upkeep-copy { max-width:620px; }
|
||||||
|
.upkeep-form { display:flex; align-items:flex-end; gap:.65rem; flex-wrap:wrap; }
|
||||||
|
.upkeep-form .form-group { min-width:120px; }
|
||||||
');
|
');
|
||||||
?>
|
?>
|
||||||
<main class="page" role="main">
|
<main class="page" role="main">
|
||||||
@@ -183,6 +200,29 @@ render_head('Admin — TyClifford.com', '
|
|||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<div class="admin-main">
|
<div class="admin-main">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p class="section-heading">Upkeeping</p>
|
||||||
|
<div class="upkeep-row">
|
||||||
|
<div class="upkeep-copy">
|
||||||
|
<h2 class="card-title">Correct YouTube timestamps</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Fetches original YouTube publish dates and updates the database timestamp shown in the admin list and public catalogue.
|
||||||
|
<?= $youtube_timestamp_candidates ?> YouTube-backed video<?= $youtube_timestamp_candidates === 1 ? '' : 's' ?> currently match this upkeep task.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form class="upkeep-form" method="post" action="index.php">
|
||||||
|
<input type="hidden" name="action" value="correct_youtube_timestamps">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="timestamp_limit">Batch Size</label>
|
||||||
|
<input class="form-input" type="number" id="timestamp_limit" name="timestamp_limit" value="25" min="1" max="100">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary btn-sm" type="submit">
|
||||||
|
Correct Dates
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="admin-toolbar">
|
<div class="admin-toolbar">
|
||||||
<form class="search-form" method="get" action="index.php">
|
<form class="search-form" method="get" action="index.php">
|
||||||
<input class="form-input" type="search" name="q"
|
<input class="form-input" type="search" name="q"
|
||||||
@@ -214,7 +254,7 @@ render_head('Admin — TyClifford.com', '
|
|||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
<th>Sources</th>
|
<th>Sources</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Added</th>
|
<th>Date</th>
|
||||||
<th class="actions-cell">Actions</th>
|
<th class="actions-cell">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -486,10 +486,8 @@ function yt_import_videos(PDO $db, array $videos, array $selected_ids, bool $pub
|
|||||||
try {
|
try {
|
||||||
$slug = make_slug($video['title']);
|
$slug = make_slug($video['title']);
|
||||||
$thumbnail = yt_download_thumbnail($video['thumbnail_url'], $slug);
|
$thumbnail = yt_download_thumbnail($video['thumbnail_url'], $slug);
|
||||||
$db->prepare("
|
$published_at = media_youtube_publish_datetime($video['watch_url']);
|
||||||
INSERT INTO videos (title, description, slug, thumbnail, duration, sort_order, published)
|
$video_params = [
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
")->execute([
|
|
||||||
$video['title'],
|
$video['title'],
|
||||||
$video['description'],
|
$video['description'],
|
||||||
$slug,
|
$slug,
|
||||||
@@ -497,7 +495,19 @@ function yt_import_videos(PDO $db, array $videos, array $selected_ids, bool $pub
|
|||||||
(int)$video['duration'],
|
(int)$video['duration'],
|
||||||
0,
|
0,
|
||||||
$published ? 1 : 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();
|
$video_id = (int)$db->lastInsertId();
|
||||||
$db->prepare("
|
$db->prepare("
|
||||||
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
|
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
|
||||||
|
|||||||
+144
-2
@@ -306,13 +306,17 @@ function media_append_query(string $base, array $params): string {
|
|||||||
return $base . (strpos($base, '?') === false ? '?' : '&') . $query;
|
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);
|
$url = normalize_media_url($url);
|
||||||
if ($url === '') return ['body' => '', 'code' => 0, 'content_type' => '', 'error' => 'Invalid 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 = [
|
$headers = [
|
||||||
'User-Agent: Mozilla/5.0 (compatible; TyCliffordMedia/1.0)',
|
'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')) {
|
if (function_exists('curl_init')) {
|
||||||
@@ -411,6 +415,144 @@ function media_youtube_id_from_url(string $url): string {
|
|||||||
return media_clean_token($id);
|
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_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 {
|
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')) {
|
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);
|
return 'https://www.youtube.com/oembed?format=json&url=' . rawurlencode($url);
|
||||||
|
|||||||
Reference in New Issue
Block a user